@musodojo/note-collection-selector 5.3.0 → 6.0.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.
@@ -14,19 +14,48 @@
14
14
  font-size: 1.5em;
15
15
  }
16
16
 
17
+ #wrapper {
18
+ display: flex;
19
+ gap: 1em;
20
+ }
21
+
22
+ button {
23
+ font: inherit;
24
+ background: none;
25
+ }
26
+
27
+ button,
17
28
  note-collection-selector::part(main-button) {
18
29
  border: 0.2em solid currentColor;
19
30
  border-radius: 0.6em;
20
31
  padding: 0.3em 1em;
32
+ background: linear-gradient(
33
+ 45deg,
34
+ transparent,
35
+ light-dark(rgb(0 0 0 / 35%), rgb(255 255 255 / 35%))
36
+ );
21
37
  }
22
38
  </style>
23
39
  </head>
24
40
  <body>
25
- <h2>Select a Note Collection</h2>
26
- <note-collection-selector></note-collection-selector>
41
+ <h2>Select from a Note Collection Selector</h2>
42
+ <div id="wrapper">
43
+ <note-collection-selector></note-collection-selector>
44
+ <note-collection-selector>
45
+ <svg viewBox="0 0 100 100">
46
+ <circle cx="50" cy="50" r="50" fill="currentColor" />
47
+ </svg>
48
+ </note-collection-selector>
49
+ <note-collection-selector>
50
+ <img
51
+ src="./scale.png"
52
+ alt="scale"
53
+ style="object-fit: contain; border-radius: 0.2rem"
54
+ />
55
+ </note-collection-selector>
56
+ </div>
27
57
  <br />
28
- <br />
29
- <button id="random-button">Random Collection</button>
58
+ <button id="random-button">Randomize</button>
30
59
 
31
60
  <script type="module" src="../dist/bundle.js"></script>
32
61
  <script type="module">
@@ -36,12 +65,17 @@
36
65
  console.log("note collection selected:", event.detail);
37
66
  },
38
67
  );
39
- const noteCollectionSelector = document.querySelector(
68
+
69
+ const noteCollectionSelector = document.querySelectorAll(
40
70
  "note-collection-selector",
41
71
  );
72
+
42
73
  const randomButton = document.getElementById("random-button");
74
+
43
75
  randomButton.addEventListener("click", () => {
44
- noteCollectionSelector.setRandomNoteCollection();
76
+ noteCollectionSelector.forEach((selector) =>
77
+ selector.setRandomNoteCollection()
78
+ );
45
79
  });
46
80
  </script>
47
81
  </body>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@musodojo/note-collection-selector",
3
- "version": "5.3.0",
3
+ "version": "6.0.0",
4
4
  "description": "A custom HTML element for selecting a note collection, and dispatching events with the details.",
5
5
  "keywords": [
6
6
  "music",
package/src/mod.ts CHANGED
@@ -1,11 +1,38 @@
1
+ /**
2
+ * @module
3
+ * A custom element for selecting musical note collections (scales, chords, etc.).
4
+ *
5
+ * @fires {CustomEvent<NoteCollectionSelectedEventDetail>} note-collection-selected - Dispatched when a note collection is selected.
6
+ *
7
+ * @example
8
+ * ```html
9
+ * <note-collection-selector
10
+ * id="my-selector"
11
+ * selected-note-collection-key="ionian"
12
+ * ></note-collection-selector>
13
+ * ```
14
+ *
15
+ * ```javascript
16
+ * const selector = document.getElementById('my-selector');
17
+ * selector.addEventListener('note-collection-selected', (event) => {
18
+ * console.log('Selected key:', event.detail.noteCollectionKey);
19
+ * console.log('Selected collection:', event.detail.noteCollection);
20
+ * });
21
+ *
22
+ * // Programmatically change the selection
23
+ * selector.selectedNoteCollectionKey = 'dorian';
24
+ * ```
25
+ */
1
26
  import {
2
27
  groupedNoteCollections,
3
- type NoteCollection,
4
- type NoteCollectionGroupKey,
5
28
  noteCollectionGroupsMetadata,
6
- type NoteCollectionKey,
7
29
  noteCollections,
8
30
  } from "@musodojo/music-theory-data";
31
+ import type {
32
+ NoteCollection,
33
+ NoteCollectionGroupKey,
34
+ NoteCollectionKey,
35
+ } from "@musodojo/music-theory-data";
9
36
 
10
37
  const noteCollectionSelectorTemplate = document.createElement("template");
11
38
  noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
@@ -44,27 +71,29 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
44
71
 
45
72
  min-width: var(--_main-icon-size);
46
73
 
47
- > #main-button-text-span {
74
+ & > #main-button-text-span {
48
75
  grid-area: 1 / 1;
49
76
  }
50
77
 
51
- > slot {
78
+ & > slot {
52
79
  height: var(--_main-icon-size);
53
80
  }
54
81
 
55
- ::slotted(svg),
56
- ::slotted(img),
57
- > slot > svg {
58
- grid-area: 1 / 1;
82
+ /* Size icons, but let text content flow naturally */
83
+ & > ::slotted(svg),
84
+ & > ::slotted(img),
85
+ & > slot > svg {
59
86
  width: var(--_main-icon-size);
60
87
  height: var(--_main-icon-size);
88
+ /* Ensure icons are on the same grid cell if multiple are slotted */
89
+ grid-area: 1 / 1;
61
90
  }
62
91
  }
63
92
 
64
93
  [part="dialog"] {
65
94
  padding: var(--_default-spacing);
66
95
 
67
- > [part="close-dialog-button"] {
96
+ & > [part="close-dialog-button"] {
68
97
  display: grid;
69
98
  place-items: center;
70
99
  padding: var(--_default-spacing);
@@ -73,9 +102,9 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
73
102
  margin-block-end: var(--_default-spacing);
74
103
 
75
104
  /* Size icons, but let text content flow naturally */
76
- ::slotted(svg),
77
- ::slotted(img),
78
- > slot[name="close-dialog-icon"] > svg {
105
+ & > ::slotted(svg),
106
+ & > ::slotted(img),
107
+ & > slot[name="close-dialog-icon"] > svg {
79
108
  width: var(--_close-dialog-icon-size);
80
109
  height: var(--_close-dialog-icon-size);
81
110
  /* Ensure icons are on the same grid cell if multiple are slotted */
@@ -83,39 +112,42 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
83
112
  }
84
113
  }
85
114
 
86
- > #toggle-more-info-label {
115
+ & > [part="clear-selection-button"],
116
+ & > #toggle-more-info-label {
87
117
  padding: 0.5em;
88
118
  border: 0.1em solid currentColor;
89
119
  border-radius: 0.5em;
90
120
  cursor: pointer;
91
121
  }
92
-
93
- > #note-collections-div {
122
+
123
+ & > #note-collections-div {
94
124
  display: flex;
95
125
  flex-direction: column;
96
- gap: 1em;
97
- margin-block-start: 2em;
126
+ gap: 2em;
127
+ margin-block-start: 1.5em;
98
128
 
99
- > #note-collection-group-wrapper {
100
- > #note-collection-group-div {
129
+ & > #note-collection-group-wrapper {
130
+ & > #note-collection-group-div {
101
131
  margin-block: 0.5em;
102
132
  display: flex;
103
133
  flex-wrap: wrap;
104
134
  gap: 1em;
105
135
  }
106
136
 
107
- > h3 {
108
- margin: 0em;
137
+ & > h3 {
138
+ margin: 0;
139
+ font-size: 1em;
140
+ font-variant: small-caps;
109
141
  }
110
142
  }
111
- }
143
+ }
112
144
  }
113
145
 
114
146
  [part="dialog"]::backdrop {
115
147
  background: var(--_dialog-backdrop-background);
116
148
  }
117
149
 
118
- .note-collection-option {
150
+ [part="note-collection-button"] {
119
151
  padding: 0.5em;
120
152
  min-width: 4ch;
121
153
  max-width: 80ch;
@@ -123,10 +155,16 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
123
155
  border-radius: 0.5em;
124
156
  cursor: pointer;
125
157
  text-align: left;
126
- text-wrap: pretty; /* Enable smart text wrapping if supported */
158
+ text-wrap: pretty;
159
+
160
+ & > .note-collection-name {
161
+ /* font-weight: bold; */
162
+ font-size: 0.9em;
163
+ }
127
164
 
128
- > h4 {
129
- margin-block: 0.2em;
165
+ /* Style for the currently selected option */
166
+ &[data-selected="true"] {
167
+ outline: 0.3em double light-dark(black, white);
130
168
  }
131
169
 
132
170
  /* When the "more info" div is hidden, center the text */
@@ -134,13 +172,14 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
134
172
  text-align: center;
135
173
  }
136
174
 
137
- > .more-info-div {
175
+ & > .more-info-div {
138
176
  display: flex;
139
177
  flex-direction: column;
140
178
  gap: 0.5em;
141
179
  }
142
180
 
143
- > .more-info-div.hidden {
181
+ /* needed a .hidden with a higher specificity here */
182
+ & > .more-info-div.hidden {
144
183
  display: none;
145
184
  }
146
185
  }
@@ -196,6 +235,8 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
196
235
  more info
197
236
  </label>
198
237
 
238
+ <button part="clear-selection-button">Clear Selection</button>
239
+
199
240
  <div id="note-collections-div">
200
241
  <!-- the buttons in here are dynamically generated
201
242
  each with an attribute of part="note-collection-button" -->
@@ -203,11 +244,20 @@ noteCollectionSelectorTemplate.innerHTML = /* HTML */ `
203
244
  </dialog>
204
245
  `;
205
246
 
247
+ /**
248
+ * The detail object for the `note-collection-selected` custom event.
249
+ */
206
250
  export interface NoteCollectionSelectedEventDetail {
251
+ /** The unique key of the selected note collection (e.g., "ionian"), or `null` if cleared. */
207
252
  noteCollectionKey: NoteCollectionKey | null;
253
+ /** The full data object for the selected note collection, or `null` if cleared. */
208
254
  noteCollection: NoteCollection | null;
209
255
  }
210
256
 
257
+ /**
258
+ * A web component that allows users to select a musical note collection.
259
+ * It displays a button which, when clicked, opens a dialog with a list of options.
260
+ */
211
261
  export class NoteCollectionSelector extends HTMLElement {
212
262
  #shadowRoot: ShadowRoot;
213
263
 
@@ -219,16 +269,29 @@ export class NoteCollectionSelector extends HTMLElement {
219
269
  #dialog!: HTMLDialogElement;
220
270
  #closeDialogButton!: HTMLButtonElement;
221
271
  #toggleMoreInfoCheckbox!: HTMLInputElement;
272
+ #clearSelectionButton!: HTMLButtonElement;
222
273
  #noteCollectionsDiv!: HTMLDivElement;
223
274
 
275
+ /** Caches the currently selected button element in the dialog to avoid re-querying the DOM. */
276
+ #selectedButtonElement: HTMLButtonElement | null = null;
277
+
278
+ /** Manages event listeners to allow for cleanup on disconnection. */
224
279
  #abortController: AbortController | null = null;
280
+ /** The internal state for the selected note collection key (e.g., "ionian"). */
225
281
  #selectedNoteCollectionKey: NoteCollectionKey | null = null;
282
+ /** The internal state for the selected note collection key's matching object. */
226
283
  #selectedNoteCollection: NoteCollection | null = null;
227
284
 
285
+ /**
286
+ * Specifies which attributes should trigger `attributeChangedCallback` when they change.
287
+ */
228
288
  static get observedAttributes(): string[] {
229
289
  return ["selected-note-collection-key"];
230
290
  }
231
291
 
292
+ /**
293
+ * Initializes the component by creating the shadow DOM and caching essential elements.
294
+ */
232
295
  constructor() {
233
296
  super();
234
297
  this.#shadowRoot = this.attachShadow({ mode: "open" });
@@ -238,17 +301,32 @@ export class NoteCollectionSelector extends HTMLElement {
238
301
  this.#cacheDomElements();
239
302
  }
240
303
 
304
+ /**
305
+ * Called when the element is inserted into the DOM.
306
+ * Finalizes setup by populating dynamic content and attaching event listeners.
307
+ */
241
308
  connectedCallback() {
242
309
  this.#populateNoteCollectionsDiv();
243
310
  this.#addEventListeners();
244
- this.#updateMainButton();
245
311
  this.#syncSelectedNoteCollectionKeyAttribute();
312
+ this.#updateSelectedButtonElementState();
313
+ this.#updateMainButton();
246
314
  }
247
315
 
316
+ /**
317
+ * Called when the element is removed from the DOM.
318
+ * Cleans up by aborting any pending operations and removing event listeners.
319
+ */
248
320
  disconnectedCallback() {
249
321
  this.#abortController?.abort();
250
322
  }
251
323
 
324
+ /**
325
+ * Called when an observed attribute of the custom element is added, removed, or changed.
326
+ * @param name - The name of the attribute that changed.
327
+ * @param oldValue - The old value of the attribute.
328
+ * @param newValue - The new value of the attribute.
329
+ */
252
330
  attributeChangedCallback(
253
331
  name: string,
254
332
  oldValue: string | null,
@@ -256,14 +334,20 @@ export class NoteCollectionSelector extends HTMLElement {
256
334
  ) {
257
335
  // Only proceed if the attribute's value has actually changed
258
336
  if (oldValue === newValue) return;
259
- if (name === "selected-note-collection-key") {
260
- // Update the internal state with the new key
261
- this.selectedNoteCollectionKey = newValue as NoteCollectionKey | null;
262
- // Dispatch the selection event to notify consumers of the change
263
- this.#dispatchNoteCollectionSelectedEvent();
337
+
338
+ switch (name) {
339
+ case "selected-note-collection-key":
340
+ this.selectedNoteCollectionKey = newValue as NoteCollectionKey | null;
341
+ break;
342
+ default:
343
+ console.log("Unexpected attribute changed:", name);
264
344
  }
265
345
  }
266
346
 
347
+ /**
348
+ * Queries the shadow DOM for critical elements and caches them as private properties.
349
+ * Throws an error if any required element is not found, failing early.
350
+ */
267
351
  #cacheDomElements() {
268
352
  const mainButton = this.#shadowRoot.querySelector<HTMLButtonElement>(
269
353
  '[part="main-button"]',
@@ -289,6 +373,12 @@ export class NoteCollectionSelector extends HTMLElement {
289
373
  "#toggle-more-info-checkbox",
290
374
  );
291
375
 
376
+ const clearSelectionButton = this.#shadowRoot.querySelector<
377
+ HTMLButtonElement
378
+ >(
379
+ '[part="clear-selection-button"]',
380
+ );
381
+
292
382
  const noteCollectionsDiv = this.#shadowRoot.querySelector<HTMLDivElement>(
293
383
  "#note-collections-div",
294
384
  );
@@ -299,8 +389,9 @@ export class NoteCollectionSelector extends HTMLElement {
299
389
  !mainButtonSlot ||
300
390
  !dialog ||
301
391
  !closeDialogButton ||
302
- !noteCollectionsDiv ||
303
- !toggleMoreInfoCheckbox
392
+ !toggleMoreInfoCheckbox ||
393
+ !clearSelectionButton ||
394
+ !noteCollectionsDiv
304
395
  ) {
305
396
  throw new Error(
306
397
  "NoteCollectionSelector: Critical elements not found in shadow DOM.",
@@ -313,40 +404,13 @@ export class NoteCollectionSelector extends HTMLElement {
313
404
  this.#dialog = dialog;
314
405
  this.#closeDialogButton = closeDialogButton;
315
406
  this.#toggleMoreInfoCheckbox = toggleMoreInfoCheckbox;
407
+ this.#clearSelectionButton = clearSelectionButton;
316
408
  this.#noteCollectionsDiv = noteCollectionsDiv;
317
409
  }
318
410
 
319
- #addEventListeners() {
320
- // abort any previous controllers before creating a new one
321
- this.#abortController?.abort();
322
- this.#abortController = new AbortController();
323
- const { signal } = this.#abortController;
324
-
325
- this.#mainButton.addEventListener(
326
- "click",
327
- () => {
328
- this.#dialog.showModal();
329
- },
330
- { signal },
331
- );
332
-
333
- this.#closeDialogButton.addEventListener(
334
- "click",
335
- () => {
336
- this.#dialog.close();
337
- },
338
- { signal },
339
- );
340
-
341
- this.#toggleMoreInfoCheckbox.addEventListener(
342
- "change",
343
- () => {
344
- this.#updateMoreInfoVisibility();
345
- },
346
- { signal },
347
- );
348
- }
349
-
411
+ /**
412
+ * Dynamically generates and populates the note collection buttons in the dialog.
413
+ */
350
414
  #populateNoteCollectionsDiv() {
351
415
  const frag = document.createDocumentFragment();
352
416
 
@@ -357,9 +421,7 @@ export class NoteCollectionSelector extends HTMLElement {
357
421
  // wrapper for heading and note-collection
358
422
  const groupWrapper = document.createElement("div");
359
423
  groupWrapper.id = "note-collection-group-wrapper";
360
- groupWrapper.innerHTML = /* HTML */ `<h3>
361
- ${groupMetadata.displayName}
362
- </h3>`;
424
+ groupWrapper.innerHTML = `<h3>${groupMetadata.displayName}</h3>`;
363
425
 
364
426
  // more info on the group (can hide)
365
427
  const groupMoreInfoDiv = document.createElement("div");
@@ -377,26 +439,23 @@ export class NoteCollectionSelector extends HTMLElement {
377
439
 
378
440
  Object.entries(currentNoteCollectionGroup).forEach(
379
441
  ([key, collection]) => {
380
- const noteCollectionDiv = document.createElement("div");
381
- noteCollectionDiv.classList.add("note-collection-option");
382
- noteCollectionDiv.innerHTML = /* HTML */ `<h4>
442
+ const noteCollectionBtn = document.createElement("button");
443
+ noteCollectionBtn.setAttribute("part", "note-collection-button");
444
+ // Set a data attribute to use later
445
+ noteCollectionBtn.dataset.noteCollectionKey = key;
446
+ noteCollectionBtn.innerHTML = /* HTML */ `<span
447
+ class="note-collection-name"
448
+ >
383
449
  ${collection.primaryName}
384
- </h4>`;
450
+ </span>`;
385
451
 
386
452
  const collectionMoreInfoDiv = document.createElement("div");
387
453
  collectionMoreInfoDiv.classList.add("more-info-div", "hidden");
388
- collectionMoreInfoDiv.innerHTML = this.#renderMoreInfo(collection);
389
- noteCollectionDiv.appendChild(collectionMoreInfoDiv);
390
-
391
- noteCollectionDiv.addEventListener("click", () => {
392
- this.#selectedNoteCollectionKey = key as NoteCollectionKey;
393
- this.#selectedNoteCollection = collection;
394
- this.#updateMainButton();
395
- this.#syncSelectedNoteCollectionKeyAttribute();
396
- this.#dialog.close();
397
- });
398
-
399
- noteCollectionGroupDiv.appendChild(noteCollectionDiv);
454
+ collectionMoreInfoDiv.innerHTML = this.#getMoreInfoHTMLString(
455
+ collection,
456
+ );
457
+ noteCollectionBtn.appendChild(collectionMoreInfoDiv);
458
+ noteCollectionGroupDiv.appendChild(noteCollectionBtn);
400
459
  },
401
460
  );
402
461
 
@@ -408,36 +467,69 @@ export class NoteCollectionSelector extends HTMLElement {
408
467
  }
409
468
 
410
469
  /**
411
- * Renders the detailed "more info" content for a given note collection.
412
- * @private
413
- * @param {NoteCollection} noteCollection - The note collection data object.
414
- * @returns {string} An HTML string containing the detailed information.
470
+ * Attaches all necessary event listeners for the component's interactive elements.
471
+ * Uses an AbortController to facilitate easy cleanup on disconnection.
415
472
  */
416
- #renderMoreInfo(noteCollection: NoteCollection): string {
417
- // TODO: replace this with updated code
418
- // <div>${noteCollection.exampleNotes.join(", ")}</div>
419
- return /* HTML */ `
420
- <div>${noteCollection.names.join(", ")}</div>
421
- <div>${noteCollection.intervals.join(", ")}</div>
422
- <div>${noteCollection.type.join(", ")}</div>
423
- <div>${noteCollection.characteristics.join(", ")}</div>
424
- <div>${noteCollection.patternShort.join("-")}</div>
425
- <div>${noteCollection.pattern.join(", ")}</div>
426
- `;
427
- }
473
+ #addEventListeners() {
474
+ // abort any previous controllers before creating a new one
475
+ this.#abortController?.abort();
476
+ this.#abortController = new AbortController();
477
+ const { signal } = this.#abortController;
428
478
 
429
- /**
430
- * Toggles the visibility of all "more info" sections within the element
431
- * based on the state of the `toggleMoreInfoCheckbox`.
432
- * @private
433
- */
434
- #updateMoreInfoVisibility() {
435
- const moreInfoElements = this.#shadowRoot?.querySelectorAll(
436
- ".more-info-div",
437
- ) as NodeListOf<HTMLDivElement>;
438
- moreInfoElements.forEach((el) => {
439
- el.classList.toggle("hidden", !this.#toggleMoreInfoCheckbox!.checked);
440
- });
479
+ this.#mainButton.addEventListener(
480
+ "click",
481
+ () => {
482
+ this.#dialog.showModal();
483
+ this.#selectedButtonElement?.scrollIntoView({
484
+ block: "center",
485
+ behavior: "smooth",
486
+ });
487
+ },
488
+ { signal },
489
+ );
490
+
491
+ this.#closeDialogButton.addEventListener(
492
+ "click",
493
+ () => {
494
+ this.#dialog.close();
495
+ },
496
+ { signal },
497
+ );
498
+
499
+ this.#toggleMoreInfoCheckbox.addEventListener(
500
+ "change",
501
+ () => {
502
+ this.#updateMoreInfoVisibility();
503
+ },
504
+ { signal },
505
+ );
506
+
507
+ this.#clearSelectionButton.addEventListener(
508
+ "click",
509
+ () => {
510
+ this.selectedNoteCollectionKey = null;
511
+ this.#dialog.close();
512
+ },
513
+ { signal },
514
+ );
515
+
516
+ this.#noteCollectionsDiv.addEventListener(
517
+ "click",
518
+ (event) => {
519
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
520
+ '[part="note-collection-button"]',
521
+ );
522
+ if (button) {
523
+ this.selectedNoteCollectionKey =
524
+ button.dataset.noteCollectionKey as NoteCollectionKey ??
525
+ null;
526
+ this.#dialog.close();
527
+ } else {
528
+ console.warn("no note collection button found");
529
+ }
530
+ },
531
+ { signal },
532
+ );
441
533
  }
442
534
 
443
535
  /**
@@ -465,13 +557,65 @@ export class NoteCollectionSelector extends HTMLElement {
465
557
  }
466
558
  }
467
559
 
560
+ #updateSelectedButtonElementState() {
561
+ // Clear the highlight from the previously selected button
562
+ if (this.#selectedButtonElement) {
563
+ this.#selectedButtonElement.removeAttribute("data-selected");
564
+ this.#selectedButtonElement = null;
565
+ }
566
+
567
+ // Add the highlight to the newly selected button
568
+ if (this.#selectedNoteCollectionKey) {
569
+ const newSelectedButton = this.#noteCollectionsDiv.querySelector<
570
+ HTMLButtonElement
571
+ >(
572
+ `[data-note-collection-key="${this.#selectedNoteCollectionKey}"]`,
573
+ );
574
+ newSelectedButton?.setAttribute("data-selected", "true");
575
+ this.#selectedButtonElement = newSelectedButton;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Renders the detailed "more info" content for a given note collection.
581
+ * @private
582
+ * @param {NoteCollection} noteCollection - The note collection data object.
583
+ * @returns {string} A HTML string containing the detailed information.
584
+ */
585
+ #getMoreInfoHTMLString(noteCollection: NoteCollection): string {
586
+ // TODO: replace this with updated code
587
+ // <div>${noteCollection.exampleNotes.join(", ")}</div>
588
+ return /* HTML */ `
589
+ <div>${noteCollection.names.join(", ")}</div>
590
+ <div>${noteCollection.intervals.join(", ")}</div>
591
+ <div>${noteCollection.type.join(", ")}</div>
592
+ <div>${noteCollection.characteristics.join(", ")}</div>
593
+ <div>${noteCollection.patternShort.join("-")}</div>
594
+ <div>${noteCollection.pattern.join(", ")}</div>
595
+ `;
596
+ }
597
+
598
+ /**
599
+ * Toggles the visibility of all "more info" sections within the element
600
+ * based on the state of the `toggleMoreInfoCheckbox`.
601
+ * @private
602
+ */
603
+ #updateMoreInfoVisibility() {
604
+ const moreInfoElements = this.#shadowRoot?.querySelectorAll(
605
+ ".more-info-div",
606
+ ) as NodeListOf<HTMLDivElement>;
607
+ moreInfoElements.forEach((el) => {
608
+ el.classList.toggle("hidden", !this.#toggleMoreInfoCheckbox.checked);
609
+ });
610
+ }
611
+
468
612
  /**
469
613
  * Synchronizes the `selected-note-collection-key` attribute on the host element
470
614
  * with the component's internal state.
471
615
  * @private
472
616
  */
473
617
  #syncSelectedNoteCollectionKeyAttribute() {
474
- if (this.#selectedNoteCollectionKey) {
618
+ if (this.#selectedNoteCollectionKey !== null) {
475
619
  this.setAttribute(
476
620
  "selected-note-collection-key",
477
621
  this.#selectedNoteCollectionKey,
@@ -515,6 +659,8 @@ export class NoteCollectionSelector extends HTMLElement {
515
659
  noteCollections,
516
660
  ) as NoteCollectionKey[];
517
661
 
662
+ if (noteCollectionKeys.length <= 1) return;
663
+
518
664
  let randomNoteCollectionKey: NoteCollectionKey;
519
665
 
520
666
  // Keep selecting at random until it's different from the current one.
@@ -528,7 +674,9 @@ export class NoteCollectionSelector extends HTMLElement {
528
674
 
529
675
  /**
530
676
  * Gets the unique key of the currently selected note collection.
531
- * @prop {NoteCollectionKey | null} selectedNoteCollectionKey
677
+ * Setting this property updates the component's selection and dispatches a `note-collection-selected` event.
678
+ *
679
+ * @attr {NoteCollectionKey | null} selected-note-collection-key - Reflects the selected note collection key.
532
680
  * @returns {NoteCollectionKey | null} The note collection key (e.g., "ionian") or `null` if no collection is selected.
533
681
  */
534
682
  get selectedNoteCollectionKey(): NoteCollectionKey | null {
@@ -539,34 +687,52 @@ export class NoteCollectionSelector extends HTMLElement {
539
687
  * Sets the currently selected note collection by its unique key.
540
688
  * This will update the component's display and internal state. If a valid key
541
689
  * is provided, the corresponding `NoteCollection` object will be looked up
542
- * and stored internally. Setting to `null` clears the selection.
690
+ * and stored internally along witht the key.
691
+ * An invalid key is equivalent to setting the key to null.
692
+ * Setting to `null` clears the selection.
543
693
  * @param {NoteCollectionKey | null} newNoteCollectionKey - The unique key of the note collection to select.
544
- * @prop {NoteCollectionKey | null} selectedNoteCollectionKey
545
694
  */
546
695
  set selectedNoteCollectionKey(
547
696
  newNoteCollectionKey: NoteCollectionKey | null,
548
697
  ) {
549
- // Look up the full note collection object based on the key,
550
- // set values to null if key is null or invalid
551
- if (
552
- newNoteCollectionKey !== null &&
553
- noteCollections[newNoteCollectionKey] !== undefined
554
- ) {
555
- this.#selectedNoteCollectionKey = newNoteCollectionKey;
556
- this.#selectedNoteCollection = noteCollections[newNoteCollectionKey];
557
- } else {
558
- this.#selectedNoteCollectionKey = null;
559
- this.#selectedNoteCollection = null;
698
+ // Resolve the input to a valid key or null
699
+ const resolvedNoteCollection =
700
+ noteCollections[newNoteCollectionKey as NoteCollectionKey] as
701
+ | NoteCollection
702
+ | undefined;
703
+ const resolvedKey = newNoteCollectionKey !== null &&
704
+ resolvedNoteCollection !== undefined
705
+ ? newNoteCollectionKey
706
+ : null;
707
+
708
+ const hasChanged = this.#selectedNoteCollectionKey !== resolvedKey;
709
+
710
+ if (hasChanged) {
711
+ this.#selectedNoteCollectionKey = resolvedKey;
712
+ this.#selectedNoteCollection = resolvedKey
713
+ ? resolvedNoteCollection as NoteCollection
714
+ : null;
715
+ }
716
+
717
+ // Only perform DOM updates and dispatch events if the component is connected
718
+ if (this.isConnected) {
719
+ // invalid values must sync attribute to null no matter what
720
+ // e.g. A user or script sets the attribute to an invalid value
721
+ // <note-collection-selector selected-note-collection-key="InvalidValue">
722
+ // should be synced to null
723
+ this.#syncSelectedNoteCollectionKeyAttribute();
724
+ if (hasChanged) {
725
+ this.#updateMainButton();
726
+ this.#updateSelectedButtonElementState();
727
+ this.#dispatchNoteCollectionSelectedEvent();
728
+ }
560
729
  }
561
- this.#updateMainButton();
562
- this.#syncSelectedNoteCollectionKeyAttribute();
563
- // No need to dispatch event here, as attributeChangedCallback will do it
564
730
  }
565
731
 
566
732
  /**
567
733
  * Gets the full data object for the currently selected note collection.
568
- * This property is read-only and is derived from `selectedNoteCollectionKey`.
569
- * @prop {NoteCollection | null} selectedNoteCollection
734
+ * This property is read-only and is derived from the `selectedNoteCollectionKey`.
735
+ *
570
736
  * @returns {NoteCollection | null} The full note collection object or `null` if no collection is selected.
571
737
  * @readonly
572
738
  */