@bygghammar/cms-platform 0.65.0 → 0.66.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -300,4 +300,4 @@
300
300
  "cms-platform-migrations": "scripts/sync-migrations.mjs",
301
301
  "create-cms-site": "./scripts/create-cms-site.mjs"
302
302
  }
303
- }
303
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState, type TouchEvent as ReactTouchEvent } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { Check, ChevronLeft, ChevronRight } from "lucide-react";
3
3
  import { useT } from "../../i18n";
4
4
  import { Reveal } from "../../ui/Reveal";
@@ -11,99 +11,48 @@ const TONE: Record<string, string> = {
11
11
  ice: "bg-ice",
12
12
  };
13
13
 
14
- /** Gradient start colour for the mobile rail edge fades, per section tone. */
15
- const FADE: Record<string, string> = {
16
- white: "from-white",
17
- paper: "from-paper",
18
- ice: "from-ice",
19
- };
20
-
21
- /** Minimum horizontal travel (px) before a touch counts as a category swipe. */
22
- const SWIPE_THRESHOLD = 48;
23
-
24
14
  /**
25
15
  * Cabin categories as clickable tabs — each tab keeps its own description,
26
16
  * bullet list and image gallery. Everything is editable in the page builder.
27
- *
28
- * On mobile the tabs become a snap-scrolling "category rail": the active tab is
29
- * auto-centred, edge fades signal that more categories exist, and a prev/next
30
- * stepper with a counter lets people move without swiping at all.
31
17
  */
32
18
  export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
33
19
  const { t } = useT();
34
20
  const items = props.items ?? [];
35
21
  const [active, setActive] = useState(0);
36
22
  const [slide, setSlide] = useState(0);
37
- const [edges, setEdges] = useState({ start: false, end: false });
23
+ const [scroll, setScroll] = useState({ value: 0, max: 0 });
38
24
 
39
25
  const railRef = useRef<HTMLDivElement | null>(null);
40
- const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
41
- const touchStart = useRef<{ x: number; y: number } | null>(null);
42
26
 
43
- const count = items.length;
44
- const index = count > 0 ? Math.min(active, count - 1) : 0;
45
-
46
- const syncEdges = useCallback(() => {
27
+ // Keep the mobile slider in sync with how far the tab row is scrolled.
28
+ const syncScroll = useCallback(() => {
47
29
  const el = railRef.current;
48
30
  if (!el) return;
49
- const max = el.scrollWidth - el.clientWidth;
50
- setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
31
+ setScroll({ value: el.scrollLeft, max: Math.max(0, el.scrollWidth - el.clientWidth) });
51
32
  }, []);
52
33
 
53
34
  useEffect(() => {
54
- syncEdges();
35
+ syncScroll();
55
36
  const el = railRef.current;
56
37
  if (!el) return;
57
- el.addEventListener("scroll", syncEdges, { passive: true });
58
- window.addEventListener("resize", syncEdges);
38
+ el.addEventListener("scroll", syncScroll, { passive: true });
39
+ window.addEventListener("resize", syncScroll);
59
40
  return () => {
60
- el.removeEventListener("scroll", syncEdges);
61
- window.removeEventListener("resize", syncEdges);
41
+ el.removeEventListener("scroll", syncScroll);
42
+ window.removeEventListener("resize", syncScroll);
62
43
  };
63
- }, [syncEdges, count]);
64
-
65
- // Keep the active tab fully visible in the middle of the rail.
66
- useEffect(() => {
67
- const tab = tabRefs.current[index];
68
- if (!tab) return;
69
- tab.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
70
- }, [index]);
44
+ }, [syncScroll, items.length]);
71
45
 
72
- const select = useCallback((i: number) => {
73
- setActive(i);
74
- setSlide(0);
75
- }, []);
46
+ if (items.length === 0) return null;
76
47
 
77
- const step = useCallback(
78
- (delta: number) => {
79
- if (count === 0) return;
80
- select((index + delta + count) % count);
81
- },
82
- [count, index, select],
83
- );
84
-
85
- if (count === 0) return null;
86
-
87
- const item = items[index];
48
+ const item = items[Math.min(active, items.length - 1)];
88
49
  const images = (item.images ?? []).filter((i) => i.src);
89
50
  const image = images.length > 0 ? images[slide % images.length] : undefined;
90
- const toneKey = props.tone ?? "white";
91
- const tone = TONE[toneKey] ?? TONE.white;
92
- const fade = FADE[toneKey] ?? FADE.white;
51
+ const tone = TONE[props.tone ?? "white"] ?? "bg-white";
93
52
 
94
- const onTouchStart = (e: ReactTouchEvent) => {
95
- const touch = e.touches[0];
96
- touchStart.current = { x: touch.clientX, y: touch.clientY };
97
- };
98
- const onTouchEnd = (e: ReactTouchEvent) => {
99
- const start = touchStart.current;
100
- touchStart.current = null;
101
- if (!start || count < 2) return;
102
- const touch = e.changedTouches[0];
103
- const dx = touch.clientX - start.x;
104
- const dy = touch.clientY - start.y;
105
- if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy) * 1.5) return;
106
- step(dx < 0 ? 1 : -1);
53
+ const select = (i: number) => {
54
+ setActive(i);
55
+ setSlide(0);
107
56
  };
108
57
 
109
58
  return (
@@ -133,93 +82,65 @@ export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
133
82
 
134
83
  <Reveal>
135
84
  <div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10">
136
- {/* Category rail (mobile) / vertical tab list (desktop) */}
85
+ {/* Tabs */}
137
86
  <div className="min-w-0">
138
- <div className="relative lg:static">
139
- <div
140
- ref={railRef}
141
- role="tablist"
142
- aria-label={t(props.title) || "Categories"}
143
- onKeyDown={(e) => {
144
- if (e.key === "ArrowRight" || e.key === "ArrowDown") {
145
- e.preventDefault();
146
- step(1);
147
- } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
148
- e.preventDefault();
149
- step(-1);
150
- }
151
- }}
152
- className="-mx-6 flex snap-x snap-mandatory gap-2 overflow-x-auto scroll-smooth px-6 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:mx-0 lg:flex-col lg:overflow-visible lg:px-0 lg:pb-0"
153
- >
154
- {items.map((it, i) => {
155
- const on = i === index;
156
- return (
157
- <button
158
- key={it.id ?? i}
159
- ref={(el) => {
160
- tabRefs.current[i] = el;
161
- }}
162
- role="tab"
163
- aria-selected={on}
164
- tabIndex={on ? 0 : -1}
165
- onClick={() => select(i)}
166
- className={`shrink-0 snap-center border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
167
- on
168
- ? "border-sea bg-sea text-white"
169
- : "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
170
- }`}
171
- >
172
- {t(it.label)}
173
- </button>
174
- );
175
- })}
176
- </div>
177
-
178
- {/* Edge fades: a tab disappearing under a gradient reads as "more". */}
179
- <div
180
- aria-hidden
181
- className={`pointer-events-none absolute inset-y-0 -left-6 w-10 bg-gradient-to-r ${fade} to-transparent transition-opacity duration-200 lg:hidden ${
182
- edges.start ? "opacity-100" : "opacity-0"
183
- }`}
184
- />
185
- <div
186
- aria-hidden
187
- className={`pointer-events-none absolute inset-y-0 -right-6 w-12 bg-gradient-to-l ${fade} to-transparent transition-opacity duration-200 lg:hidden ${
188
- edges.end ? "opacity-100" : "opacity-0"
189
- }`}
190
- />
191
- </div>
192
-
193
- {count > 1 ? (
194
- <div className="mt-3 flex items-center justify-center gap-3 lg:hidden">
195
- <button
196
- type="button"
197
- onClick={() => step(-1)}
198
- aria-label="Föregående kategori"
199
- className="border border-ink/12 bg-white p-2 text-ink/70 transition-colors hover:border-sea hover:text-ink"
200
- >
201
- <ChevronLeft className="size-4" />
202
- </button>
203
- <span
204
- aria-live="polite"
205
- className="min-w-0 truncate text-[0.7rem] uppercase tracking-[0.2em] text-ink/60"
206
- >
207
- {t(item.label)} · {index + 1} / {count}
208
- </span>
87
+ <div className="relative lg:static">
88
+ <div
89
+ ref={railRef}
90
+ role="tablist"
91
+ aria-label={t(props.title) || "Categories"}
92
+ className="flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:flex-col lg:overflow-visible lg:pb-0"
93
+ >
94
+ {items.map((it, i) => {
95
+ const on = i === Math.min(active, items.length - 1);
96
+ return (
209
97
  <button
210
- type="button"
211
- onClick={() => step(1)}
212
- aria-label="Nästa kategori"
213
- className="border border-ink/12 bg-white p-2 text-ink/70 transition-colors hover:border-sea hover:text-ink"
98
+ key={it.id ?? i}
99
+ role="tab"
100
+ aria-selected={on}
101
+ onClick={() => select(i)}
102
+ className={`shrink-0 border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
103
+ on
104
+ ? "border-sea bg-sea text-white"
105
+ : "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
106
+ }`}
214
107
  >
215
- <ChevronRight className="size-4" />
108
+ {t(it.label)}
216
109
  </button>
110
+ );
111
+ })}
112
+ </div>
113
+
114
+ {/* Arrow hint: there is more to the right. */}
115
+ {scroll.max > 4 && scroll.value < scroll.max - 4 ? (
116
+ <div
117
+ aria-hidden
118
+ className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 bg-white/85 p-1 text-sea shadow-sm lg:hidden"
119
+ >
120
+ <ChevronRight className="size-5" />
217
121
  </div>
218
122
  ) : null}
219
123
  </div>
220
124
 
125
+ {/* Slider: drag to move through the categories. */}
126
+ {scroll.max > 4 ? (
127
+ <input
128
+ type="range"
129
+ min={0}
130
+ max={scroll.max}
131
+ value={Math.min(scroll.value, scroll.max)}
132
+ aria-label="Bläddra bland hyttkategorier"
133
+ onChange={(e) => {
134
+ const el = railRef.current;
135
+ if (el) el.scrollLeft = Number(e.target.value);
136
+ }}
137
+ className="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-ink/12 accent-sea lg:hidden"
138
+ />
139
+ ) : null}
140
+ </div>
141
+
221
142
  {/* Copy */}
222
- <div className="min-w-0" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
143
+ <div className="min-w-0">
223
144
  <h3 className="font-serif text-2xl md:text-3xl text-sea">
224
145
  {t(item.title) || t(item.label)}
225
146
  </h3>