@bygghammar/cms-platform 0.65.0 → 0.67.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/CHANGELOG.md +20 -0
- package/package.json +2 -2
- package/src/blocks/components/CabinTabsBlock.tsx +59 -148
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.67.0
|
|
4
|
+
|
|
5
|
+
- `CabinTabsBlock`: removed the mobile right-edge arrow hint; the horizontal tab
|
|
6
|
+
rail and synchronized range slider remain.
|
|
7
|
+
|
|
8
|
+
## 0.66.0
|
|
9
|
+
|
|
10
|
+
- `CabinTabsBlock`: reverted to the original horizontal tab row on mobile and
|
|
11
|
+
added a synchronized range slider below it plus a right-edge scroll hint arrow.
|
|
12
|
+
|
|
13
|
+
## 0.65.0
|
|
14
|
+
|
|
15
|
+
- `CabinTabsBlock`: mobile category rail with snap scrolling, edge fades,
|
|
16
|
+
active-tab auto-centering, previous/next stepper, and swipe support.
|
|
17
|
+
|
|
18
|
+
## 0.64.0
|
|
19
|
+
|
|
20
|
+
- Backoffice navigation, dashboard, and module screens now respect the saved
|
|
21
|
+
feature/module switches from Site settings.
|
|
22
|
+
|
|
3
23
|
## 0.63.0
|
|
4
24
|
|
|
5
25
|
- Site config gains `branding` (`logo`, `logoDark`, `logoAlt`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.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": [
|
|
@@ -298,6 +298,6 @@
|
|
|
298
298
|
},
|
|
299
299
|
"bin": {
|
|
300
300
|
"cms-platform-migrations": "scripts/sync-migrations.mjs",
|
|
301
|
-
"create-cms-site": "
|
|
301
|
+
"create-cms-site": "scripts/create-cms-site.mjs"
|
|
302
302
|
}
|
|
303
303
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState
|
|
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 [
|
|
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
|
-
|
|
43
|
-
const count = items.length;
|
|
44
|
-
const index = count > 0 ? Math.min(active, count - 1) : 0;
|
|
45
26
|
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
syncScroll();
|
|
55
36
|
const el = railRef.current;
|
|
56
37
|
if (!el) return;
|
|
57
|
-
el.addEventListener("scroll",
|
|
58
|
-
window.addEventListener("resize",
|
|
38
|
+
el.addEventListener("scroll", syncScroll, { passive: true });
|
|
39
|
+
window.addEventListener("resize", syncScroll);
|
|
59
40
|
return () => {
|
|
60
|
-
el.removeEventListener("scroll",
|
|
61
|
-
window.removeEventListener("resize",
|
|
41
|
+
el.removeEventListener("scroll", syncScroll);
|
|
42
|
+
window.removeEventListener("resize", syncScroll);
|
|
62
43
|
};
|
|
63
|
-
}, [
|
|
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]);
|
|
71
|
-
|
|
72
|
-
const select = useCallback((i: number) => {
|
|
73
|
-
setActive(i);
|
|
74
|
-
setSlide(0);
|
|
75
|
-
}, []);
|
|
44
|
+
}, [syncScroll, items.length]);
|
|
76
45
|
|
|
77
|
-
|
|
78
|
-
(delta: number) => {
|
|
79
|
-
if (count === 0) return;
|
|
80
|
-
select((index + delta + count) % count);
|
|
81
|
-
},
|
|
82
|
-
[count, index, select],
|
|
83
|
-
);
|
|
46
|
+
if (items.length === 0) return null;
|
|
84
47
|
|
|
85
|
-
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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,55 @@ 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
|
-
{/*
|
|
85
|
+
{/* Tabs */}
|
|
137
86
|
<div className="min-w-0">
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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">
|
|
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 (
|
|
195
97
|
<button
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
aria-
|
|
199
|
-
|
|
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
|
+
}`}
|
|
200
107
|
>
|
|
201
|
-
|
|
108
|
+
{t(it.label)}
|
|
202
109
|
</button>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Slider: drag to move through the categories. */}
|
|
116
|
+
{scroll.max > 4 ? (
|
|
117
|
+
<input
|
|
118
|
+
type="range"
|
|
119
|
+
min={0}
|
|
120
|
+
max={scroll.max}
|
|
121
|
+
value={Math.min(scroll.value, scroll.max)}
|
|
122
|
+
aria-label="Bläddra bland hyttkategorier"
|
|
123
|
+
onChange={(e) => {
|
|
124
|
+
const el = railRef.current;
|
|
125
|
+
if (el) el.scrollLeft = Number(e.target.value);
|
|
126
|
+
}}
|
|
127
|
+
className="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-ink/12 accent-sea lg:hidden"
|
|
128
|
+
/>
|
|
129
|
+
) : null}
|
|
219
130
|
</div>
|
|
220
131
|
|
|
221
132
|
{/* Copy */}
|
|
222
|
-
<div className="min-w-0"
|
|
133
|
+
<div className="min-w-0">
|
|
223
134
|
<h3 className="font-serif text-2xl md:text-3xl text-sea">
|
|
224
135
|
{t(item.title) || t(item.label)}
|
|
225
136
|
</h3>
|