astro-better-release-notes 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -92,9 +92,9 @@ const rendered = await Promise.all(
92
92
 
93
93
  Two CSS custom properties control the positioning:
94
94
 
95
- `--release-notes-top` — how far the sticky header sits from the viewport top (should match your site's fixed nav height). Default: `3.5rem`.
95
+ `--release-notes-top` -- how far the sticky header sits from the viewport top (should match your site's fixed nav height). Default: `3.5rem`.
96
96
 
97
- `--release-notes-scroll-margin` — the `scroll-margin-top` applied to both version articles and individual release note rows when navigating via anchor links. Should be at least as large as your total sticky height (nav + release-notes sticky header). Default: `5rem`.
97
+ `--release-notes-scroll-margin` -- the `scroll-margin-top` applied to version headings and individual release note rows, which are the targets of anchor links and the version selector. Set it to at least the distance from the viewport top to the bottom edge of the release notes sticky header (your nav plus the selector and chip rows), or anchored headings land underneath the header. The chip rows wrap with viewport width, so measure at your breakpoints. Default: `5rem`.
98
98
 
99
99
  ```css
100
100
  /* in your root layout or global CSS */
@@ -124,11 +124,30 @@ The github issue links and category badge hover colors are configurable:
124
124
  | `--release-notes-link-color-dark` | `#818cf8` (indigo-400) | Dark mode link color |
125
125
  | `--release-notes-link-hover-color-dark` | `#fed7aa` (orange-200) | Dark mode hover color |
126
126
 
127
+ ### Theme variables
128
+
129
+ Set these on `:root` (and `.dark` for dark mode) to match your site. Each falls back to the default shown.
130
+
131
+ | Variable | Light default | Dark default | Used for |
132
+ |----------|---------------|--------------|----------|
133
+ | `--rn-bg` | `white` | `#0f172a` | Sticky header background |
134
+ | `--rn-border` | `#d1d5db` | `#4b5563` | Selector and filter button borders |
135
+ | `--rn-input-bg` | `#f3f4f6` | `#1e293b` | Selector and filter button background |
136
+ | `--rn-text` | `#111827` | `#cbd5e1` | Selector text |
137
+ | `--rn-text-muted` | `#374151` | `#cbd5e1` | Filter button text |
138
+ | `--rn-focus` | `#6366f1` | `#818cf8` | Focus rings and the active-filter indicator |
139
+ | `--rn-chip-bg` | `#f1f5f9` | `#1e293b` | Unselected chip background |
140
+ | `--rn-chip-border` | `#cbd5e1` | `#475569` | Unselected chip border |
141
+ | `--rn-chip-color` | `#334155` | `#cbd5e1` | Unselected chip text |
142
+ | `--rn-color-{category}` | see `DEFAULT_CATEGORIES` | same | Category color for chips and badges |
143
+
144
+ If you map these to your own palette, check that `--rn-chip-color` and `--rn-text-muted` keep a 4.5:1 contrast ratio against `--rn-chip-bg` and `--rn-input-bg`.
145
+
127
146
  ### Mobile filter behavior
128
147
 
129
148
  On narrow screens the category filter chips are hidden behind a "Filter" toggle button that sits next to the version selector. Tapping it expands the chips below the selector row. When any filter is active, the button changes color as a visual indicator.
130
149
 
131
- On screens 768px and wider, the chips are always visible.
150
+ On screens 1024px and wider, the chips are always visible.
132
151
 
133
152
  ## Custom categories
134
153
 
@@ -211,6 +230,24 @@ import { ReleaseNotesSelector } from 'astro-better-release-notes';
211
230
  </div>
212
231
  ```
213
232
 
233
+ ## Accessibility
234
+
235
+ - Each version is an `<article>` labelled by its `<h2>`. The `<h2>` carries the anchor id (`version-1-2-3`), so fragment links, search crawlers, and TOC tools all resolve to the heading. The `#` permalink sits next to the heading rather than inside it, so the heading's accessible name is only the version.
236
+ - Category filters are native checkboxes inside a `<fieldset>` with a visually hidden `<legend>`. They work with Tab and Space, show a focus ring on the chip, and announce their checked state. Selected chips show a check mark as well as color.
237
+ - Badge and selected-chip text colors are mixed from the category color (darker in light mode, lighter in dark mode), so they stay at or above 4.5:1 for the default categories and for most custom mid-tone colors.
238
+ - The version selector has a label and announces each jump through a polite live region. Focus stays on the selector, because Chrome and Edge on Windows fire `change` on every arrow key press.
239
+ - Transitions and the badge hover scale are disabled under `prefers-reduced-motion: reduce`.
240
+ - The package ships its own `.rn-sr-only` class, so it doesn't depend on a host `sr-only` utility.
241
+
242
+ ## Upgrading to 0.2.2
243
+
244
+ Anchor URLs are unchanged, but the markup moved:
245
+
246
+ - The version id moved from `<article class="version">` to its `<h2>`. Update any CSS or scripts that target `article.version[id]` or `.version:target`.
247
+ - `--release-notes-scroll-margin` now applies to the heading instead of the article, which starts slightly higher. If anchored headings land under your sticky header, increase the value (see [Sticky offset and scroll margins](#sticky-offset-and-scroll-margins)).
248
+ - The permalink is now `.version-title .anchor-link`, outside the `<h2>`. Selectors using `.release-note .anchor-link` need updating.
249
+ - The filter container is a `<fieldset>` instead of a `<nav>`, and keeps the `release-notes-filters` id and `filter-controls` class.
250
+
214
251
  ## Dark mode
215
252
 
216
253
  The stylesheet uses a `.dark` ancestor class (compatible with Tailwind's `darkMode: 'class'`). To also enable automatic OS-based dark mode, add to your project CSS:
@@ -71,6 +71,7 @@ const filterCSS = [
71
71
  {showSelector && <ReleaseNotesSelector {updates} />}
72
72
 
73
73
  <button
74
+ type="button"
74
75
  class="filter-toggle-btn"
75
76
  aria-expanded="false"
76
77
  aria-controls="release-notes-filters"
@@ -81,14 +82,15 @@ const filterCSS = [
81
82
  Filter
82
83
  </button>
83
84
 
84
- <nav id="release-notes-filters" class="filter-controls" aria-label="Filter release notes by category">
85
+ <fieldset id="release-notes-filters" class="filter-controls">
86
+ <legend class="rn-sr-only">Show only these categories</legend>
85
87
  {categories.map(cat => (
86
88
  <label class="filter-chip" style={`--cat-color: ${cat.color}`}>
87
- <input type="checkbox" id={`toggle-${cat.id}`} class="filter-input" aria-label={`Show ${cat.label}`} />
88
- <span aria-hidden="true">{cat.label}</span>
89
+ <input type="checkbox" id={`toggle-${cat.id}`} class="filter-input" />
90
+ {cat.label}
89
91
  </label>
90
92
  ))}
91
- </nav>
93
+ </fieldset>
92
94
 
93
95
  <noscript><style>@media (max-width: 1023px) { #release-notes-filters { display: flex !important; flex-wrap: wrap; } .filter-toggle-btn { display: none !important; } }</style></noscript>
94
96
  </div>
@@ -98,12 +100,15 @@ const filterCSS = [
98
100
  const { Content } = item;
99
101
  const versionId = `version-${item.data.version.replaceAll('.', '-')}`;
100
102
  return (
101
- <article id={versionId} class="version">
103
+ <article class="version" aria-labelledby={versionId}>
102
104
  <header class="version-header">
103
- <h2 class="release-note group">
104
- Version {item.data.version} {item.data.name && `(${item.data.name})`}
105
- <a class="anchor-link" title="copy header link" href={`#${versionId}`} aria-label={`Permalink to version ${item.data.version}`}>#</a>
106
- </h2>
105
+ {/* id on h2 so crawlers resolve anchors; permalink outside h2 keeps the heading name clean */}
106
+ <div class="version-title">
107
+ <h2 id={versionId} class="release-note">
108
+ Version {item.data.version}{item.data.name && ` (${item.data.name})`}
109
+ </h2>
110
+ <a class="anchor-link" href={`#${versionId}`} aria-label={`Permalink to version ${item.data.version}`}>#</a>
111
+ </div>
107
112
  <time class="version-date" datetime={item.data.date.toISOString()}>
108
113
  {item.data.date.toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}
109
114
  </time>
@@ -15,7 +15,7 @@ const { updates } = Astro.props as Props;
15
15
  ---
16
16
 
17
17
  <div class="release-selector">
18
- <label for="release-notes-version" class="sr-only">Jump to a release version</label>
18
+ <label for="release-notes-version" class="rn-sr-only">Jump to a release version</label>
19
19
  <select
20
20
  id="release-notes-version"
21
21
  class="release-selector-select"
@@ -27,15 +27,19 @@ const { updates } = Astro.props as Props;
27
27
  return <option value={versionId}>{label}</option>;
28
28
  })}
29
29
  </select>
30
+ <p id="release-notes-version-status" class="rn-sr-only" aria-live="polite"></p>
30
31
  </div>
31
32
 
32
33
  <script is:inline>
33
34
  const initVersionSpy = () => {
35
+ // each article's anchor id lives on its h2
36
+ const headingId = (article) => article.querySelector('h2[id]')?.id;
34
37
  const selectEl = document.getElementById('release-notes-version');
35
38
  if (!selectEl) return;
36
39
 
37
40
  const placeholderOption = selectEl.querySelector('option[value=""]');
38
- const versions = Array.from(document.querySelectorAll('article.version[id]'));
41
+ const statusEl = document.getElementById('release-notes-version-status');
42
+ const versions = Array.from(document.querySelectorAll('article.version')).filter(a => headingId(a));
39
43
  if (versions.length === 0) return;
40
44
 
41
45
  const HEADER_OFFSET = 210;
@@ -61,9 +65,9 @@ const { updates } = Astro.props as Props;
61
65
  }
62
66
  }
63
67
 
64
- if (activeArticle && activeArticle.id !== currentActiveId) {
65
- currentActiveId = activeArticle.id;
66
- selectEl.value = activeArticle.id;
68
+ if (activeArticle && headingId(activeArticle) !== currentActiveId) {
69
+ currentActiveId = headingId(activeArticle);
70
+ selectEl.value = currentActiveId;
67
71
  disablePlaceholder();
68
72
  }
69
73
  };
@@ -79,17 +83,19 @@ const { updates } = Astro.props as Props;
79
83
  }
80
84
  };
81
85
 
82
- // navigate on change; aria-live on the region announces the jump for screen readers
86
+ // focus stays on the select (arrow keys fire change on windows), so announce the jump instead
83
87
  selectEl.addEventListener('change', () => {
84
88
  hasUserInteracted = true;
85
89
  disablePlaceholder();
86
- if (selectEl.value) window.location.hash = selectEl.value;
90
+ if (!selectEl.value) return;
91
+ window.location.hash = selectEl.value;
92
+ if (statusEl) statusEl.textContent = `Jumped to ${selectEl.selectedOptions[0]?.textContent ?? selectEl.value}`;
87
93
  });
88
94
 
89
95
  const filterControls = document.querySelector('.filter-controls');
90
96
  if (filterControls) {
91
97
  filterControls.addEventListener('change', () => {
92
- const targetVersion = versions.find(a => a.id === currentActiveId) || versions[0];
98
+ const targetVersion = versions.find(a => headingId(a) === currentActiveId) || versions[0];
93
99
  window.requestAnimationFrame(() => {
94
100
  if (targetVersion && hasUserInteracted) {
95
101
  const adjustment = targetVersion.getBoundingClientRect().top - HEADER_OFFSET;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-better-release-notes",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Configurable release notes components with category filtering and version navigation for Astro",
5
5
  "keywords": [
6
6
  "astro",
package/style.css CHANGED
@@ -162,21 +162,38 @@
162
162
  flex-wrap: wrap;
163
163
  gap: 0.75rem;
164
164
  padding: 0.25rem 0;
165
+ /* fieldset resets */
166
+ border: 0;
167
+ margin: 0;
168
+ min-inline-size: 0;
165
169
  }
166
170
 
171
+ /* visually hidden but still focusable and announced; the chip shows its state */
167
172
  .filter-input {
168
- display: none;
173
+ position: absolute;
174
+ width: 1px;
175
+ height: 1px;
176
+ margin: -1px;
177
+ padding: 0;
178
+ overflow: hidden;
179
+ clip-path: inset(50%);
180
+ white-space: nowrap;
181
+ border: 0;
182
+ }
183
+
184
+ .filter-chip:has(.filter-input:focus-visible) {
185
+ outline: 2px solid var(--rn-focus, #6366f1);
186
+ outline-offset: 2px;
169
187
  }
170
188
 
171
189
  .filter-chip {
190
+ position: relative;
172
191
  padding: 0.5rem 1rem;
173
192
  font-size: 0.875rem;
174
193
  font-weight: 500;
175
194
  border-radius: 9999px;
176
195
  cursor: pointer;
177
196
  transition: filter 0.15s ease-in-out;
178
- opacity: 0.7;
179
- filter: grayscale(100%);
180
197
  color: var(--rn-chip-color, #334155);
181
198
  background-color: var(--rn-chip-bg, #f1f5f9);
182
199
  border: 1px solid var(--rn-chip-border, #cbd5e1);
@@ -189,19 +206,28 @@
189
206
  }
190
207
 
191
208
  .filter-chip:hover {
192
- filter: grayscale(100%) brightness(0.95);
209
+ filter: brightness(0.95);
193
210
  }
194
211
 
195
212
  .dark .filter-chip:hover {
196
- filter: grayscale(100%) brightness(1.1);
213
+ filter: brightness(1.1);
197
214
  }
198
215
 
216
+ /* selected: tint + border + check so state isn't color-only; text mixes keep 4.5:1 */
199
217
  .filter-chip:has(input:checked) {
200
- filter: grayscale(0);
201
- opacity: 1;
202
- background-color: var(--cat-color) !important;
218
+ background-color: color-mix(in srgb, var(--cat-color) 18%, white) !important;
203
219
  border-color: var(--cat-color) !important;
204
- color: #fff !important;
220
+ color: color-mix(in srgb, var(--cat-color) 45%, black) !important;
221
+ }
222
+
223
+ .dark .filter-chip:has(input:checked) {
224
+ background-color: color-mix(in srgb, var(--cat-color) 18%, #1e293b) !important;
225
+ color: color-mix(in srgb, var(--cat-color) 60%, white) !important;
226
+ }
227
+
228
+ .filter-chip:has(input:checked)::before {
229
+ content: "\2713" / "";
230
+ margin-right: 0.375rem;
205
231
  }
206
232
 
207
233
  /* ---------------------------------------------------------------
@@ -261,10 +287,14 @@
261
287
  transition: transform 0.15s ease-out, color 0.15s ease-out;
262
288
  transform-origin: left center;
263
289
  background-color: color-mix(in srgb, var(--cat-color) 13%, transparent);
264
- color: var(--cat-color);
290
+ color: color-mix(in srgb, var(--cat-color) 45%, black);
265
291
  border: 1px solid color-mix(in srgb, var(--cat-color) 27%, transparent);
266
292
  }
267
293
 
294
+ .dark .issue-badge {
295
+ color: color-mix(in srgb, var(--cat-color) 60%, white);
296
+ }
297
+
268
298
  .issue-badge:hover {
269
299
  transform: scale(1.05);
270
300
  color: var(--release-notes-link-hover-color, #9a3412);
@@ -323,7 +353,13 @@
323
353
  Version articles
324
354
  --------------------------------------------------------------- */
325
355
 
326
- .version {
356
+ .version-title {
357
+ display: flex;
358
+ align-items: baseline;
359
+ }
360
+
361
+ /* the heading carries the anchor id */
362
+ .release-note[id] {
327
363
  scroll-margin-top: var(--release-notes-scroll-margin, 5rem);
328
364
  }
329
365
 
@@ -345,11 +381,13 @@
345
381
  color: #64748b;
346
382
  }
347
383
 
384
+ .dark .version-date { color: #94a3b8; }
385
+
348
386
  /* ---------------------------------------------------------------
349
387
  Version anchor links (# permalink)
350
388
  --------------------------------------------------------------- */
351
389
 
352
- .release-note .anchor-link {
390
+ .version-title .anchor-link {
353
391
  opacity: 0;
354
392
  text-decoration: none;
355
393
  border: none;
@@ -358,7 +396,37 @@
358
396
  transition: opacity 0.15s ease;
359
397
  }
360
398
 
361
- .release-note:hover .anchor-link,
362
- .release-note .anchor-link:focus-visible {
399
+ .version-title:hover .anchor-link,
400
+ .version-title .anchor-link:focus-visible {
363
401
  opacity: 1;
364
402
  }
403
+
404
+ /* ---------------------------------------------------------------
405
+ Utilities and motion
406
+ --------------------------------------------------------------- */
407
+
408
+ .rn-sr-only {
409
+ position: absolute;
410
+ width: 1px;
411
+ height: 1px;
412
+ margin: -1px;
413
+ padding: 0;
414
+ overflow: hidden;
415
+ clip-path: inset(50%);
416
+ white-space: nowrap;
417
+ border: 0;
418
+ }
419
+
420
+ @media (prefers-reduced-motion: reduce) {
421
+ .filter-chip,
422
+ .issue-badge,
423
+ .github-link,
424
+ .version-title .anchor-link {
425
+ transition: none;
426
+ }
427
+
428
+ .issue-badge:hover,
429
+ .issue-badge:active {
430
+ transform: none;
431
+ }
432
+ }