@sarimarcus/content-sites-ui 0.29.3 → 0.29.4

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.29.4 (2026-10-07)
6
+
7
+ - `./composed`: `Pagination`'s current page uses `text-primary-foreground` (was `text-white`, 1.8:1 on a light primary) and Previous/Next are named by their visible text; `TableOfContents`' hidden mini bar is `inert`; `CardCarousel`'s arrows use `aria-disabled`, so the focused arrow keeps focus at an edge, and arrows and dots have 44px targets (SLA-2984).
8
+ - `./seo`: `ArticleMetadata`'s byline is a labelled `group`, no longer a second `contentinfo` landmark inside `<main>` (SLA-2984).
9
+ - `./navigation`: `NavDropdown` opens only through `aria-expanded` (no `:focus-within`), so Escape and click-to-close stay closed while focus is inside, and drops `aria-haspopup`; the mobile menu closes and releases the body scroll lock when the viewport crosses `lg`; `SiteFooter`'s legal links are 44px tall (SLA-2984).
10
+
5
11
  ## 0.29.3 (2026-10-07)
6
12
 
7
13
  - `./layout`: `ConsentAnalytics` builds the absolute `gtm.js` URL at render time and passes it to the inline script, which no longer holds a relative `'/gtm.js?id='` literal that Googlebot crawled as a 404 on each site (SLA-3001).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.29.3",
3
+ "version": "0.29.4",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,6 @@
49
49
  "@lucide/astro": "^1.23.0",
50
50
  "astro": "^7.0.6",
51
51
  "tailwindcss": "^4.1.18",
52
- "@sarimarcus/content-sites-core": "0.29.3"
52
+ "@sarimarcus/content-sites-core": "0.29.4"
53
53
  }
54
54
  }
@@ -51,6 +51,9 @@ const HeadingTag = headingTag;
51
51
  // Ids must be unique per page AND stable across builds.
52
52
  const containerId = deterministicId('cc', Astro.url.pathname, id, ariaLabel ?? heading);
53
53
  const trackId = `${containerId}-t`;
54
+ // The ::before grows the 36px circle into a 44px touch target.
55
+ const arrowClass =
56
+ 'relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors before:absolute before:-inset-1 hover:border-primary/40 hover:bg-primary/10 aria-disabled:pointer-events-none aria-disabled:opacity-25';
54
57
  ---
55
58
 
56
59
  <div id={id} data-carousel={containerId} class:list={['scroll-mt-28', className]}>
@@ -80,8 +83,8 @@ const trackId = `${containerId}-t`;
80
83
  data-carousel-prev
81
84
  type="button"
82
85
  aria-label={`Previous, ${ariaLabel ?? heading ?? 'carousel'}`}
83
- disabled
84
- class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
86
+ aria-disabled="true"
87
+ class={arrowClass}
85
88
  >
86
89
  <Icon name="chevron-left" class="h-4 w-4" aria-hidden="true" />
87
90
  </button>
@@ -89,7 +92,7 @@ const trackId = `${containerId}-t`;
89
92
  data-carousel-next
90
93
  type="button"
91
94
  aria-label={`Next, ${ariaLabel ?? heading ?? 'carousel'}`}
92
- class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card transition-colors hover:border-primary/40 hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-25"
95
+ class={arrowClass}
93
96
  >
94
97
  <Icon name="chevron-right" class="h-4 w-4" aria-hidden="true" />
95
98
  </button>
@@ -106,7 +109,7 @@ const trackId = `${containerId}-t`;
106
109
  >
107
110
  <slot />
108
111
  </div>
109
- <div data-carousel-dots class="mt-4 hidden justify-center gap-2"></div>
112
+ <div data-carousel-dots class="mt-4 hidden justify-center"></div>
110
113
  </div>
111
114
 
112
115
  <style is:global>
@@ -44,7 +44,7 @@ const edgeBase = `${linkBase} px-3`;
44
44
  {totalPages > 1 && (
45
45
  <nav aria-label="Pagination" class="mt-14 flex items-center justify-center gap-2 flex-wrap">
46
46
  {prevPage ? (
47
- <a href={href(prevPage)} rel="prev" aria-label={`Go to page ${prevPage}`} class={edgeBase}>
47
+ <a href={href(prevPage)} rel="prev" class={edgeBase}>
48
48
  <Icon name="arrow-left" class="w-3.5 h-3.5" />
49
49
  <span>Previous</span>
50
50
  </a>
@@ -65,7 +65,7 @@ const edgeBase = `${linkBase} px-3`;
65
65
  <span
66
66
  aria-current="page"
67
67
  aria-label={`Page ${item}, current page`}
68
- class={`${numberBase} border-primary bg-primary text-white hover:text-white`}
68
+ class={`${numberBase} border-primary bg-primary text-primary-foreground hover:text-primary-foreground`}
69
69
  >
70
70
  {item}
71
71
  </span>
@@ -82,7 +82,7 @@ const edgeBase = `${linkBase} px-3`;
82
82
  </div>
83
83
 
84
84
  {nextPage ? (
85
- <a href={href(nextPage)} rel="next" aria-label={`Go to page ${nextPage}`} class={edgeBase}>
85
+ <a href={href(nextPage)} rel="next" class={edgeBase}>
86
86
  <span>Next</span>
87
87
  <Icon name="arrow-right" class="w-3.5 h-3.5" />
88
88
  </a>
@@ -71,6 +71,7 @@ const showMini = variant === 'card';
71
71
  data-toc-section-selector={sectionSelector}
72
72
  class="fixed top-16 left-0 right-0 z-40 translate-y-[-100%] opacity-0 pointer-events-none transition-[transform,opacity] duration-300 ease-out bg-background/95 backdrop-blur-sm border-b border-border shadow-sm"
73
73
  aria-hidden="true"
74
+ inert
74
75
  >
75
76
  <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-2.5">
76
77
  <nav class="flex gap-2 overflow-x-auto scrollbar-hide" aria-label={`${ariaLabel} (sticky)`}>
@@ -17,18 +17,26 @@ function initCarousel(section: HTMLElement): void {
17
17
  const dotsWrap = section.querySelector<HTMLElement>('[data-carousel-dots]');
18
18
  let currentIndex = 0;
19
19
 
20
+ // Each dot is a 44px hit area around its 6px pill; the negative margin keeps the row 6px tall.
20
21
  const dots: HTMLButtonElement[] = [];
22
+ const pills: HTMLElement[] = [];
21
23
  if (dotsWrap) {
22
24
  cards.forEach((_, i) => {
23
25
  const dot = document.createElement('button');
24
26
  dot.type = 'button';
25
27
  dot.setAttribute('aria-label', `Go to item ${i + 1}`);
26
28
  dot.style.cssText =
27
- 'height:0.375rem;border-radius:9999px;transition:all 0.2s;border:0;cursor:pointer;padding:0;' +
29
+ 'display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;' +
30
+ 'margin:-1.1875rem 0;border:0;padding:0;background:transparent;cursor:pointer;';
31
+ const pill = document.createElement('span');
32
+ pill.style.cssText =
33
+ 'display:block;height:0.375rem;border-radius:9999px;transition:all 0.2s;' +
28
34
  (i === 0 ? `width:1rem;background:${ACTIVE};` : `width:0.375rem;background:${INACTIVE};`);
35
+ dot.appendChild(pill);
29
36
  dot.addEventListener('click', () => scrollToIndex(i));
30
37
  dotsWrap.appendChild(dot);
31
38
  dots.push(dot);
39
+ pills.push(pill);
32
40
  });
33
41
  }
34
42
 
@@ -43,14 +51,17 @@ function initCarousel(section: HTMLElement): void {
43
51
  currentIndex = Math.max(0, Math.min(idx, cards.length - 1));
44
52
  // Disabled state comes from the scroll position, not the card index: with several cards per view the track hits
45
53
  // max scroll while currentIndex is still mid-list.
46
- if (prevBtn) prevBtn.disabled = startEdge;
47
- if (nextBtn) nextBtn.disabled = endEdge;
54
+ // aria-disabled, not disabled: disabling the focused arrow would drop focus to body.
55
+ prevBtn?.setAttribute('aria-disabled', String(startEdge));
56
+ nextBtn?.setAttribute('aria-disabled', String(endEdge));
48
57
  // At the extremes the dot shows the first or last card, otherwise the trailing dots could never light up.
49
58
  const dotIndex = endEdge ? cards.length - 1 : startEdge ? 0 : currentIndex;
50
59
  dots.forEach((dot, i) => {
51
60
  const active = i === dotIndex;
52
- dot.style.width = active ? '1rem' : '0.375rem';
53
- dot.style.background = active ? ACTIVE : INACTIVE;
61
+ pills[i].style.width = active ? '1rem' : '0.375rem';
62
+ pills[i].style.background = active ? ACTIVE : INACTIVE;
63
+ if (active) dot.setAttribute('aria-current', 'true');
64
+ else dot.removeAttribute('aria-current');
54
65
  });
55
66
  }
56
67
 
@@ -81,8 +92,9 @@ function initCarousel(section: HTMLElement): void {
81
92
  });
82
93
  }, { passive: true });
83
94
 
84
- prevBtn?.addEventListener('click', () => scrollToIndex(currentIndex - 1));
85
- nextBtn?.addEventListener('click', () => scrollToIndex(currentIndex + 1));
95
+ const enabled = (btn: HTMLButtonElement) => btn.getAttribute('aria-disabled') !== 'true';
96
+ prevBtn?.addEventListener('click', () => enabled(prevBtn) && scrollToIndex(currentIndex - 1));
97
+ nextBtn?.addEventListener('click', () => enabled(nextBtn) && scrollToIndex(currentIndex + 1));
86
98
 
87
99
  // Controls show only when the track overflows; re-checked whenever the track resizes.
88
100
  function syncControls(): void {
@@ -12,6 +12,7 @@ function setMiniVisible(mini: Element, visible: boolean): void {
12
12
  mini.classList.toggle('opacity-100', visible);
13
13
  mini.classList.toggle('pointer-events-auto', visible);
14
14
  mini.setAttribute('aria-hidden', visible ? 'false' : 'true');
15
+ mini.toggleAttribute('inert', !visible);
15
16
  document.dispatchEvent(new CustomEvent('toc:mini-visibility-change', { detail: { visible } }));
16
17
  }
17
18
 
@@ -125,16 +125,14 @@ const featureStyle = featureBg
125
125
  )}
126
126
 
127
127
  <style>
128
- /* data-opened is set on the first open and kept; :focus-within covers a keyboard visit before any script runs. */
129
- :global([data-nav-dropdown][data-opened]) .articles-nav-feature,
130
- :global(.nav-dropdown:focus-within) .articles-nav-feature {
128
+ /* data-opened is set on the first open and kept, so the image never unpaints mid-fade. */
129
+ :global([data-nav-dropdown][data-opened]) .articles-nav-feature {
131
130
  background-image: var(--articles-nav-feature, none);
132
131
  }
133
132
 
134
133
  /* A var() holding an unsupported image-set() computes to none, so older engines get the 1x file instead. */
135
134
  @supports not (background-image: image-set(url('x.webp') 1x)) {
136
- :global([data-nav-dropdown][data-opened]) .articles-nav-feature,
137
- :global(.nav-dropdown:focus-within) .articles-nav-feature {
135
+ :global([data-nav-dropdown][data-opened]) .articles-nav-feature {
138
136
  background-image: var(--articles-nav-feature-1x, none);
139
137
  }
140
138
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  /**
3
- * A desktop dropdown: the trigger button (aria-expanded/haspopup/controls) and the panel the site fills through the
4
- * default slot. The panel opens on click, hover (closing after a short delay), ArrowDown and keyboard focus; the
3
+ * A desktop dropdown: the trigger button (aria-expanded/controls) and the panel the site fills through the
4
+ * default slot. The panel opens on click, hover (closing after a short delay) and ArrowDown, never on focus alone; the
5
5
  * SiteHeader controller drives it. `buttonClass` replaces the default trigger styling, which `tone` tints.
6
6
  */
7
7
  import { Icon } from '../primitives/index.ts';
@@ -37,7 +37,6 @@ const triggerClass = buttonClass ?? [
37
37
  class:list={['nav-dropdown-btn', triggerClass]}
38
38
  aria-current={current ? 'page' : undefined}
39
39
  aria-expanded="false"
40
- aria-haspopup="true"
41
40
  aria-controls={id}
42
41
  data-nav-dropdown-button
43
42
  >
@@ -61,16 +60,16 @@ const triggerClass = buttonClass ?? [
61
60
  pointer-events: none;
62
61
  }
63
62
 
64
- .nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu,
65
- .nav-dropdown:focus-within .dropdown-menu {
63
+ /* visibility flips at once on open, so ArrowDown can focus a link in the same tick; closing still fades. */
64
+ .nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu {
65
+ transition: opacity 200ms, visibility 0s, transform 200ms;
66
66
  opacity: 1;
67
67
  visibility: visible;
68
68
  transform: translateY(0);
69
69
  pointer-events: auto;
70
70
  }
71
71
 
72
- .nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron),
73
- .nav-dropdown:focus-within :global(.dropdown-chevron) {
72
+ .nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron) {
74
73
  transform: rotate(180deg);
75
74
  }
76
75
 
@@ -49,7 +49,8 @@ const {
49
49
  ringClass = 'rounded focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-inverse',
50
50
  } = Astro.props;
51
51
 
52
- const legalItemClass = `${smallClass} hover:text-inverse-foreground transition-colors ${ringClass}`;
52
+ // A real 44px box, not an overflowing hit area: wrapped rows would overlap and steal each other's taps.
53
+ const legalItemClass = `${smallClass} inline-flex min-h-11 items-center hover:text-inverse-foreground transition-colors ${ringClass}`;
53
54
  ---
54
55
 
55
56
  <footer class={className}>
@@ -113,6 +113,13 @@ function searchPathOf(root: HTMLElement): string | null {
113
113
  return (window.location.pathname.indexOf(compiled) === 0 ? compiled : '') + path;
114
114
  }
115
115
 
116
+ function closeOpenMobileMenus(refocus: boolean): void {
117
+ roots().forEach((root) => {
118
+ const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
119
+ if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, refocus);
120
+ });
121
+ }
122
+
116
123
  function bindDocument(): void {
117
124
  if (documentBound) return;
118
125
  documentBound = true;
@@ -125,10 +132,7 @@ function bindDocument(): void {
125
132
 
126
133
  document.addEventListener('keydown', (event) => {
127
134
  if (event.key === 'Escape') {
128
- roots().forEach((root) => {
129
- const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
130
- if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, true);
131
- });
135
+ closeOpenMobileMenus(true);
132
136
  return;
133
137
  }
134
138
  if (!(event.metaKey || event.ctrlKey) || event.key !== 'k') return;
@@ -140,6 +144,11 @@ function bindDocument(): void {
140
144
  event.preventDefault();
141
145
  window.location.assign(`${searchPath}?focus=1`);
142
146
  });
147
+
148
+ // Tailwind's lg: the panel hides itself past it, so the body scroll lock must go with it.
149
+ window.matchMedia('(min-width: 64rem)').addEventListener('change', (event) => {
150
+ if (event.matches) closeOpenMobileMenus(false);
151
+ });
143
152
  }
144
153
 
145
154
  export function initNavigation(): void {
@@ -84,7 +84,7 @@ const grouped = bylineWhitespace === 'grouped';
84
84
  const bylineLead = author ? (grouped ? ' ' : ' ') : (grouped ? '' : ' ');
85
85
  ---
86
86
 
87
- <address class="not-italic mb-5" role="contentinfo" aria-label={ariaLabel}>
87
+ <address class="not-italic mb-5" role="group" aria-label={ariaLabel}>
88
88
  <div class="flex items-start gap-3">
89
89
  {author && (author.image ? (
90
90
  <Image