@sumaq/site-kit 0.2.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.
Files changed (60) hide show
  1. package/README.md +93 -0
  2. package/package.json +57 -0
  3. package/src/astro-modules.d.ts +11 -0
  4. package/src/blocks/About.astro +94 -0
  5. package/src/blocks/Audience.astro +66 -0
  6. package/src/blocks/BlockRenderer.astro +105 -0
  7. package/src/blocks/Contact.astro +58 -0
  8. package/src/blocks/ContactDetails.astro +91 -0
  9. package/src/blocks/CtaBand.astro +53 -0
  10. package/src/blocks/EmergencyGrid.astro +47 -0
  11. package/src/blocks/EmergencyHelplines.astro +109 -0
  12. package/src/blocks/EmergencyPrimary.astro +51 -0
  13. package/src/blocks/Faq.astro +56 -0
  14. package/src/blocks/FeatureList.astro +73 -0
  15. package/src/blocks/Gallery.astro +95 -0
  16. package/src/blocks/Generic.astro +30 -0
  17. package/src/blocks/Hero.astro +82 -0
  18. package/src/blocks/IntroCta.astro +41 -0
  19. package/src/blocks/LegalDocument.astro +83 -0
  20. package/src/blocks/LocationsOverview.astro +161 -0
  21. package/src/blocks/NotFound.astro +33 -0
  22. package/src/blocks/Notice.astro +49 -0
  23. package/src/blocks/PageBanner.astro +57 -0
  24. package/src/blocks/Pricing.astro +97 -0
  25. package/src/blocks/ProfileDetail.astro +202 -0
  26. package/src/blocks/Projects.astro +91 -0
  27. package/src/blocks/Quote.astro +53 -0
  28. package/src/blocks/Services.astro +46 -0
  29. package/src/blocks/Skills.astro +65 -0
  30. package/src/blocks/Stats.astro +54 -0
  31. package/src/blocks/Steps.astro +76 -0
  32. package/src/blocks/TeamDirectory.astro +164 -0
  33. package/src/blocks/TeamTeaser.astro +84 -0
  34. package/src/blocks/Testimonials.astro +75 -0
  35. package/src/blocks/Timeline.astro +65 -0
  36. package/src/components/Actions.astro +67 -0
  37. package/src/components/Card.astro +104 -0
  38. package/src/components/Logo.astro +57 -0
  39. package/src/components/Media.astro +83 -0
  40. package/src/components/Prose.astro +29 -0
  41. package/src/components/ScrollToTop.astro +49 -0
  42. package/src/components/Section.astro +75 -0
  43. package/src/components/SectionHeader.astro +58 -0
  44. package/src/components/SectionHeading.astro +76 -0
  45. package/src/components/Seo.astro +55 -0
  46. package/src/components/SiteFooter.astro +90 -0
  47. package/src/components/SiteHeader.astro +97 -0
  48. package/src/config.ts +59 -0
  49. package/src/index.ts +75 -0
  50. package/src/layouts/Base.astro +57 -0
  51. package/src/lib/collections.ts +101 -0
  52. package/src/lib/content.ts +51 -0
  53. package/src/lib/format.ts +17 -0
  54. package/src/lib/markdown.ts +5 -0
  55. package/src/profiles/clinic.yaml +35 -0
  56. package/src/profiles/portfolio.yaml +27 -0
  57. package/src/profiles/therapist.yaml +31 -0
  58. package/src/scripts/enhancements.js +459 -0
  59. package/src/tokens/class-inventory.json +284 -0
  60. package/src/tokens/index.css +83 -0
@@ -0,0 +1,459 @@
1
+ const initEnhancements = () => {
2
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3
+
4
+ // 1. Sticky Header scroll behavior
5
+ const header = document.querySelector(".sq-header");
6
+ if (header) {
7
+ const handleScroll = () => {
8
+ header.classList.toggle("is-scrolled", window.scrollY > 15);
9
+ };
10
+ window.addEventListener("scroll", handleScroll, { passive: true });
11
+ handleScroll();
12
+ }
13
+
14
+ // 2. Mobile Menu — open/close, focus return, Esc, link close
15
+ const navToggle = document.querySelector(".sq-nav__toggle");
16
+ const siteNav = document.getElementById("site-nav");
17
+
18
+ const setMenuOpen = (isOpen) => {
19
+ if (!navToggle || !siteNav) return;
20
+ siteNav.classList.toggle("is-open", isOpen);
21
+ navToggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
22
+ navToggle.setAttribute("aria-label", isOpen ? "Menü schließen" : "Menü öffnen");
23
+ document.body.classList.toggle("nav-open", isOpen);
24
+
25
+ if (isOpen) {
26
+ const firstLink = siteNav.querySelector("a, button");
27
+ if (firstLink) firstLink.focus();
28
+ } else {
29
+ navToggle.focus();
30
+ }
31
+ };
32
+
33
+ if (navToggle && siteNav && header) {
34
+ navToggle.addEventListener("click", (e) => {
35
+ e.stopPropagation();
36
+ setMenuOpen(!siteNav.classList.contains("is-open"));
37
+ });
38
+
39
+ document.addEventListener("click", (e) => {
40
+ if (!siteNav.classList.contains("is-open")) return;
41
+ if (!header.contains(e.target)) setMenuOpen(false);
42
+ });
43
+
44
+ document.addEventListener("keydown", (e) => {
45
+ if (e.key === "Escape" && siteNav.classList.contains("is-open")) {
46
+ e.preventDefault();
47
+ setMenuOpen(false);
48
+ }
49
+
50
+ // Keep Tab cycling within header+nav while menu is open on small screens
51
+ if (e.key !== "Tab" || !siteNav.classList.contains("is-open")) return;
52
+ if (window.matchMedia("(min-width: 769px)").matches) return;
53
+
54
+ const focusables = [navToggle, ...siteNav.querySelectorAll("a[href], button:not([disabled])")].filter(
55
+ (el) => el && !el.hasAttribute("disabled") && el.offsetParent !== null
56
+ );
57
+ if (!focusables.length) return;
58
+
59
+ const first = focusables[0];
60
+ const last = focusables[focusables.length - 1];
61
+ const active = document.activeElement;
62
+
63
+ if (e.shiftKey && active === first) {
64
+ e.preventDefault();
65
+ last.focus();
66
+ } else if (!e.shiftKey && active === last) {
67
+ e.preventDefault();
68
+ first.focus();
69
+ }
70
+ });
71
+
72
+ siteNav.querySelectorAll("a").forEach((link) => {
73
+ link.addEventListener("click", () => {
74
+ if (siteNav.classList.contains("is-open")) setMenuOpen(false);
75
+ });
76
+ });
77
+ }
78
+
79
+ // 3. Image Gallery Swapping (Standorte page – location thumbs)
80
+ const thumbs = document.querySelectorAll(".sq-location__thumb");
81
+ thumbs.forEach((thumb) => {
82
+ thumb.addEventListener("click", function () {
83
+ const targetId = this.getAttribute("data-target");
84
+ const mainImg = document.getElementById(targetId);
85
+ const newSrc = this.getAttribute("data-src");
86
+ const newAlt = this.getAttribute("data-alt") || "";
87
+
88
+ if (mainImg && newSrc) {
89
+ const swap = () => {
90
+ mainImg.setAttribute("src", newSrc);
91
+ mainImg.setAttribute("alt", newAlt);
92
+ mainImg.style.opacity = 1;
93
+ };
94
+
95
+ if (reduceMotion) {
96
+ swap();
97
+ } else {
98
+ mainImg.style.opacity = 0;
99
+ setTimeout(swap, 150);
100
+ }
101
+
102
+ const gallery = this.closest(".sq-location__gallery");
103
+ if (gallery) {
104
+ gallery.querySelectorAll(".sq-location__thumb").forEach((t) => t.classList.remove("is-active"));
105
+ }
106
+ this.classList.add("is-active");
107
+ }
108
+ });
109
+ });
110
+
111
+ // 3b. Room gallery dots (standorte detail)
112
+ document.querySelectorAll("[data-room-gallery]").forEach((gallery) => {
113
+ const slides = gallery.querySelectorAll(".sq-room__slides img");
114
+ const dots = gallery.querySelectorAll(".sq-room__dot");
115
+ if (!slides.length || !dots.length) return;
116
+
117
+ const showSlide = (index) => {
118
+ slides.forEach((slide, i) => {
119
+ slide.classList.toggle("is-active", i === index);
120
+ });
121
+ dots.forEach((dot, i) => {
122
+ const isActive = i === index;
123
+ dot.classList.toggle("is-active", isActive);
124
+ dot.setAttribute("aria-selected", isActive ? "true" : "false");
125
+ });
126
+ };
127
+
128
+ dots.forEach((dot) => {
129
+ dot.addEventListener("click", () => {
130
+ const index = Number(dot.getAttribute("data-index"));
131
+ if (!Number.isNaN(index)) showSlide(index);
132
+ });
133
+ });
134
+ });
135
+
136
+ // 3c. Location -> room page transition (only when "Räume ansehen" is clicked;
137
+ // every other way of reaching /standorte/[slug] keeps the default transition)
138
+ document.querySelectorAll("[data-rooms-cta]").forEach((cta) => {
139
+ cta.addEventListener("click", () => {
140
+ const id = cta.getAttribute("data-rooms-cta");
141
+ const name = document.querySelector(`[data-location-name="${id}"]`);
142
+ if (name) name.style.viewTransitionName = `location-name-${id}`;
143
+ });
144
+ });
145
+
146
+ // 4. Team Search + Category Filter
147
+ const searchInput = document.getElementById("team-search");
148
+ const teamGrid = document.getElementById("team-grid");
149
+ if (searchInput && teamGrid) {
150
+ const cards = Array.from(teamGrid.querySelectorAll(".sq-team__card"));
151
+ const counter = document.getElementById("search-counter");
152
+ const noResults = document.getElementById("no-results");
153
+ const filterButtons = Array.from(document.querySelectorAll(".sq-team__filter"));
154
+ const transitionMs = reduceMotion ? 0 : 320;
155
+ let activeFilter = "alle";
156
+ const hideTimers = new WeakMap();
157
+ let debounceTimer = null;
158
+
159
+ if (counter) {
160
+ counter.setAttribute("aria-live", "polite");
161
+ counter.setAttribute("role", "status");
162
+ }
163
+ if (noResults) {
164
+ noResults.setAttribute("role", "status");
165
+ noResults.setAttribute("aria-live", "polite");
166
+ noResults.hidden = true;
167
+ noResults.style.display = "";
168
+ }
169
+
170
+ // Copy lives in the markup (data-counter-*), not here: the kit ships no
171
+ // language. `{count}` is the only placeholder.
172
+ const counterTemplates = {
173
+ all: counter?.getAttribute("data-counter-all") || "{count}",
174
+ results: counter?.getAttribute("data-counter-results") || "{count}",
175
+ };
176
+
177
+ const updateCounter = (visibleCount, query) => {
178
+ if (!counter) return;
179
+ const template =
180
+ query === "" && activeFilter === "alle" ? counterTemplates.all : counterTemplates.results;
181
+ counter.innerHTML = template.replace(
182
+ "{count}",
183
+ `<span id="member-count">${visibleCount}</span>`
184
+ );
185
+ };
186
+
187
+ const matchesCard = (card, query) => {
188
+ const searchTerms = (card.getAttribute("data-search") || "").toLowerCase();
189
+ const categories = (card.getAttribute("data-categories") || "").toLowerCase();
190
+ const queryOk = !query || searchTerms.includes(query);
191
+ const filterOk = activeFilter === "alle" || categories.split(/\s+/).includes(activeFilter);
192
+ return queryOk && filterOk;
193
+ };
194
+
195
+ const showCard = (card) => {
196
+ const pending = hideTimers.get(card);
197
+ if (pending) {
198
+ clearTimeout(pending);
199
+ hideTimers.delete(card);
200
+ }
201
+ if (!card.classList.contains("is-hidden") && !card.classList.contains("is-hiding")) {
202
+ return;
203
+ }
204
+ card.classList.remove("is-hidden", "is-hiding");
205
+ card.removeAttribute("aria-hidden");
206
+ if (transitionMs === 0) return;
207
+ card.classList.add("is-showing");
208
+ void card.offsetWidth;
209
+ requestAnimationFrame(() => {
210
+ card.classList.remove("is-showing");
211
+ });
212
+ };
213
+
214
+ const hideCard = (card) => {
215
+ if (card.classList.contains("is-hidden") || card.classList.contains("is-hiding")) {
216
+ return;
217
+ }
218
+ if (transitionMs === 0) {
219
+ card.classList.add("is-hidden");
220
+ card.setAttribute("aria-hidden", "true");
221
+ return;
222
+ }
223
+ card.classList.add("is-hiding");
224
+ const timer = setTimeout(() => {
225
+ card.classList.add("is-hidden");
226
+ card.classList.remove("is-hiding");
227
+ card.setAttribute("aria-hidden", "true");
228
+ hideTimers.delete(card);
229
+ }, transitionMs);
230
+ hideTimers.set(card, timer);
231
+ };
232
+
233
+ const applyFilters = () => {
234
+ const query = searchInput.value.toLowerCase().trim();
235
+ let visibleCount = 0;
236
+
237
+ cards.forEach((card) => {
238
+ if (matchesCard(card, query)) {
239
+ showCard(card);
240
+ visibleCount++;
241
+ } else {
242
+ hideCard(card);
243
+ }
244
+ });
245
+
246
+ updateCounter(visibleCount, query);
247
+
248
+ if (noResults) {
249
+ const empty = visibleCount === 0;
250
+ noResults.hidden = !empty;
251
+ }
252
+ };
253
+
254
+ searchInput.addEventListener("input", () => {
255
+ clearTimeout(debounceTimer);
256
+ debounceTimer = setTimeout(applyFilters, 150);
257
+ });
258
+
259
+ filterButtons.forEach((btn) => {
260
+ btn.addEventListener("click", () => {
261
+ activeFilter = btn.getAttribute("data-filter") || "alle";
262
+ filterButtons.forEach((b) => {
263
+ const isActive = b === btn;
264
+ b.classList.toggle("is-active", isActive);
265
+ b.setAttribute("aria-pressed", isActive ? "true" : "false");
266
+ });
267
+ applyFilters();
268
+ });
269
+ });
270
+ }
271
+
272
+ // 4b. Broken image fallback (team / profile)
273
+ document.querySelectorAll("img[src^='media/team/']").forEach((img) => {
274
+ img.addEventListener("error", () => {
275
+ if (img.dataset.fallbackApplied) return;
276
+ img.dataset.fallbackApplied = "1";
277
+ img.src = "media/team/profile_placeholder.CZDfc6EN_1FnOqJ.webp";
278
+ });
279
+ });
280
+
281
+ // 4c. Gallery lightbox (<Gallery lightbox />) — one viewer per gallery,
282
+ // keyboard-navigable, restores focus to the trigger on close.
283
+ document.querySelectorAll("[data-lightbox]").forEach((viewer) => {
284
+ const image = viewer.querySelector("[data-lightbox-image]");
285
+ const gallery = document.querySelector("[data-gallery]");
286
+ const triggers = gallery ? Array.from(gallery.querySelectorAll("[data-gallery-index]")) : [];
287
+ if (!image || !triggers.length) return;
288
+
289
+ let current = 0;
290
+ let opener = null;
291
+
292
+ const show = (index) => {
293
+ current = (index + triggers.length) % triggers.length;
294
+ const trigger = triggers[current];
295
+ image.setAttribute("src", trigger.getAttribute("data-src"));
296
+ image.setAttribute("alt", trigger.getAttribute("data-alt") || "");
297
+ };
298
+
299
+ const open = (index, trigger) => {
300
+ opener = trigger;
301
+ show(index);
302
+ viewer.hidden = false;
303
+ viewer.classList.add("is-open");
304
+ const close = viewer.querySelector("[data-lightbox-close]");
305
+ if (close) close.focus();
306
+ };
307
+
308
+ const close = () => {
309
+ viewer.hidden = true;
310
+ viewer.classList.remove("is-open");
311
+ if (opener) opener.focus();
312
+ };
313
+
314
+ triggers.forEach((trigger, index) => {
315
+ trigger.addEventListener("click", () => open(index, trigger));
316
+ });
317
+
318
+ viewer.querySelector("[data-lightbox-close]")?.addEventListener("click", close);
319
+ viewer.querySelector("[data-lightbox-prev]")?.addEventListener("click", () => show(current - 1));
320
+ viewer.querySelector("[data-lightbox-next]")?.addEventListener("click", () => show(current + 1));
321
+ viewer.addEventListener("click", (e) => {
322
+ if (e.target === viewer) close();
323
+ });
324
+
325
+ document.addEventListener("keydown", (e) => {
326
+ if (viewer.hidden) return;
327
+ if (e.key === "Escape") close();
328
+ else if (e.key === "ArrowLeft") show(current - 1);
329
+ else if (e.key === "ArrowRight") show(current + 1);
330
+ });
331
+ });
332
+
333
+ // 5. Scroll Reveal Effect using IntersectionObserver
334
+ const revealElements = document.querySelectorAll(
335
+ [
336
+ ".reveal",
337
+ ".sq-location__card",
338
+ ".sq-services__card",
339
+ ".sq-audience__card",
340
+ ".sq-pricing__card",
341
+ ".sq-contact__card",
342
+ ".sq-card",
343
+ ".sq-steps__item",
344
+ ".sq-timeline__item",
345
+ ".sq-projects__item",
346
+ ".sq-skills__group",
347
+ ".sq-testimonials__card",
348
+ ".sq-gallery__item",
349
+ ].join(", ")
350
+ );
351
+ if (reduceMotion) {
352
+ revealElements.forEach((el) => el.classList.add("is-visible"));
353
+ } else if ("IntersectionObserver" in window) {
354
+ const revealObserver = new IntersectionObserver(
355
+ (entries, observer) => {
356
+ entries.forEach((entry) => {
357
+ if (entry.isIntersecting) {
358
+ entry.target.classList.add("is-visible");
359
+ observer.unobserve(entry.target);
360
+ }
361
+ });
362
+ },
363
+ {
364
+ threshold: 0.05,
365
+ rootMargin: "0px 0px -40px 0px",
366
+ }
367
+ );
368
+
369
+ revealElements.forEach((el) => {
370
+ el.classList.add("reveal");
371
+ revealObserver.observe(el);
372
+ });
373
+ } else {
374
+ revealElements.forEach((el) => {
375
+ el.classList.add("is-visible");
376
+ });
377
+ }
378
+
379
+ // 6. Nav scroll-spy — highlight in-page anchor links (services/pricing/contact) as their section scrolls into view
380
+ const navHashLinks = Array.from(document.querySelectorAll(".sq-nav__list a[href*='#']"));
381
+ const sectionLinkMap = navHashLinks
382
+ .map((link) => {
383
+ const id = link.getAttribute("href").split("#")[1];
384
+ const section = id ? document.getElementById(id) : null;
385
+ return section ? { link, section } : null;
386
+ })
387
+ .filter(Boolean);
388
+
389
+ if (sectionLinkMap.length && "IntersectionObserver" in window) {
390
+ const homeLink = document.querySelector(".sq-nav__list a[href='/']");
391
+ const intersecting = new Set();
392
+
393
+ const updateActiveLink = () => {
394
+ navHashLinks.forEach((link) => link.removeAttribute("aria-current"));
395
+ if (homeLink) homeLink.removeAttribute("aria-current");
396
+ const active = sectionLinkMap.find(({ section }) => intersecting.has(section));
397
+ if (active) {
398
+ active.link.setAttribute("aria-current", "page");
399
+ } else if (homeLink) {
400
+ homeLink.setAttribute("aria-current", "page");
401
+ }
402
+ };
403
+
404
+ const spyObserver = new IntersectionObserver(
405
+ (entries) => {
406
+ entries.forEach((entry) => {
407
+ if (entry.isIntersecting) intersecting.add(entry.target);
408
+ else intersecting.delete(entry.target);
409
+ });
410
+ updateActiveLink();
411
+ },
412
+ { rootMargin: "-45% 0px -50% 0px", threshold: 0 }
413
+ );
414
+
415
+ sectionLinkMap.forEach(({ section }) => spyObserver.observe(section));
416
+ }
417
+
418
+ // 7. Back to top — restore focus to skip target
419
+ const scrollToTop = (targetId) => {
420
+ const top = document.getElementById(targetId) || document.body;
421
+ window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
422
+ if (history.replaceState) {
423
+ history.replaceState(null, "", `#${targetId}`);
424
+ }
425
+ if (typeof top.focus === "function") {
426
+ if (!top.hasAttribute("tabindex")) top.setAttribute("tabindex", "-1");
427
+ top.focus({ preventScroll: true });
428
+ }
429
+ };
430
+
431
+ document.querySelectorAll("a.sq-to-top").forEach((link) => {
432
+ link.addEventListener("click", (e) => {
433
+ e.preventDefault();
434
+ scrollToTop("top");
435
+ });
436
+ });
437
+
438
+ // 7b. Floating back-to-top control (<ScrollToTop />) — appears past its
439
+ // own threshold, so a short page never shows it.
440
+ document.querySelectorAll("[data-scroll-top]").forEach((button) => {
441
+ const targetId = button.getAttribute("data-scroll-target") || "top";
442
+ const threshold = Number(button.getAttribute("data-scroll-threshold")) || 300;
443
+
444
+ const sync = () => {
445
+ const visible = window.scrollY > threshold;
446
+ button.hidden = !visible;
447
+ button.classList.toggle("is-visible", visible);
448
+ };
449
+
450
+ button.addEventListener("click", () => scrollToTop(targetId));
451
+ window.addEventListener("scroll", sync, { passive: true });
452
+ sync();
453
+ });
454
+ };
455
+
456
+ // astro:page-load fires after the initial page load and after every
457
+ // client-side navigation done by <ClientRouter />, so re-run setup each time
458
+ // instead of relying on DOMContentLoaded (which only fires once).
459
+ document.addEventListener("astro:page-load", initEnhancements);