@bygghammar/cms-platform 0.64.0 → 0.65.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.
|
|
3
|
+
"version": "0.65.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": [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type TouchEvent as ReactTouchEvent } 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,26 +11,99 @@ 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
|
+
|
|
14
24
|
/**
|
|
15
25
|
* Cabin categories as clickable tabs — each tab keeps its own description,
|
|
16
26
|
* 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.
|
|
17
31
|
*/
|
|
18
32
|
export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
|
|
19
33
|
const { t } = useT();
|
|
20
34
|
const items = props.items ?? [];
|
|
21
35
|
const [active, setActive] = useState(0);
|
|
22
36
|
const [slide, setSlide] = useState(0);
|
|
37
|
+
const [edges, setEdges] = useState({ start: false, end: false });
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
const railRef = useRef<HTMLDivElement | null>(null);
|
|
40
|
+
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
41
|
+
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
|
25
42
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
43
|
+
const count = items.length;
|
|
44
|
+
const index = count > 0 ? Math.min(active, count - 1) : 0;
|
|
45
|
+
|
|
46
|
+
const syncEdges = useCallback(() => {
|
|
47
|
+
const el = railRef.current;
|
|
48
|
+
if (!el) return;
|
|
49
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
50
|
+
setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
syncEdges();
|
|
55
|
+
const el = railRef.current;
|
|
56
|
+
if (!el) return;
|
|
57
|
+
el.addEventListener("scroll", syncEdges, { passive: true });
|
|
58
|
+
window.addEventListener("resize", syncEdges);
|
|
59
|
+
return () => {
|
|
60
|
+
el.removeEventListener("scroll", syncEdges);
|
|
61
|
+
window.removeEventListener("resize", syncEdges);
|
|
62
|
+
};
|
|
63
|
+
}, [syncEdges, count]);
|
|
30
64
|
|
|
31
|
-
|
|
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]);
|
|
71
|
+
|
|
72
|
+
const select = useCallback((i: number) => {
|
|
32
73
|
setActive(i);
|
|
33
74
|
setSlide(0);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
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];
|
|
88
|
+
const images = (item.images ?? []).filter((i) => i.src);
|
|
89
|
+
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;
|
|
93
|
+
|
|
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);
|
|
34
107
|
};
|
|
35
108
|
|
|
36
109
|
return (
|
|
@@ -60,34 +133,93 @@ export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
|
|
|
60
133
|
|
|
61
134
|
<Reveal>
|
|
62
135
|
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10">
|
|
63
|
-
{/*
|
|
64
|
-
<div
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
136
|
+
{/* Category rail (mobile) / vertical tab list (desktop) */}
|
|
137
|
+
<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">
|
|
72
195
|
<button
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
aria-
|
|
76
|
-
|
|
77
|
-
className={`shrink-0 border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
|
|
78
|
-
on
|
|
79
|
-
? "border-sea bg-sea text-white"
|
|
80
|
-
: "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
|
|
81
|
-
}`}
|
|
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"
|
|
82
200
|
>
|
|
83
|
-
|
|
201
|
+
<ChevronLeft className="size-4" />
|
|
84
202
|
</button>
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
|
209
|
+
<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"
|
|
214
|
+
>
|
|
215
|
+
<ChevronRight className="size-4" />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
) : null}
|
|
87
219
|
</div>
|
|
88
220
|
|
|
89
221
|
{/* Copy */}
|
|
90
|
-
<div className="min-w-0">
|
|
222
|
+
<div className="min-w-0" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
|
|
91
223
|
<h3 className="font-serif text-2xl md:text-3xl text-sea">
|
|
92
224
|
{t(item.title) || t(item.label)}
|
|
93
225
|
</h3>
|