@pixelated-tech/components 3.7.1 → 3.7.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.
Files changed (53) hide show
  1. package/dist/components/admin/site-health/site-health-on-site-seo.integration.js +15 -0
  2. package/dist/components/general/callout.js +4 -3
  3. package/dist/components/general/intersection-observer.js +117 -0
  4. package/dist/components/general/microinteractions.js +39 -50
  5. package/dist/components/general/schema-services.js +17 -4
  6. package/dist/components/general/schema-website.js +100 -7
  7. package/dist/components/general/splitscroll.css +125 -0
  8. package/dist/components/general/splitscroll.js +89 -0
  9. package/dist/components/sitebuilder/config/ConfigBuilder.js +142 -25
  10. package/dist/components/sitebuilder/config/services-form.json +51 -0
  11. package/dist/components/sitebuilder/config/siteinfo-form.json +68 -0
  12. package/dist/index.js +2 -0
  13. package/dist/scripts/generate-site-images.js +7 -4
  14. package/dist/scripts/setup-remotes.sh +69 -0
  15. package/dist/scripts/validate-exports.cjs +280 -0
  16. package/dist/types/components/admin/site-health/site-health-on-site-seo.integration.d.ts.map +1 -1
  17. package/dist/types/components/config/config.types.d.ts +8 -1
  18. package/dist/types/components/config/config.types.d.ts.map +1 -1
  19. package/dist/types/components/general/callout.d.ts +3 -2
  20. package/dist/types/components/general/callout.d.ts.map +1 -1
  21. package/dist/types/components/general/intersection-observer.d.ts +73 -0
  22. package/dist/types/components/general/intersection-observer.d.ts.map +1 -0
  23. package/dist/types/components/general/microinteractions.d.ts +1 -1
  24. package/dist/types/components/general/microinteractions.d.ts.map +1 -1
  25. package/dist/types/components/general/schema-services.d.ts +25 -6
  26. package/dist/types/components/general/schema-services.d.ts.map +1 -1
  27. package/dist/types/components/general/schema-website.d.ts +60 -5
  28. package/dist/types/components/general/schema-website.d.ts.map +1 -1
  29. package/dist/types/components/general/splitscroll.d.ts +51 -0
  30. package/dist/types/components/general/splitscroll.d.ts.map +1 -0
  31. package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts +30 -0
  32. package/dist/types/components/sitebuilder/config/ConfigBuilder.d.ts.map +1 -1
  33. package/dist/types/index.d.ts +2 -0
  34. package/dist/types/scripts/validate-exports.d.cts +2 -0
  35. package/dist/types/scripts/validate-exports.d.cts.map +1 -0
  36. package/dist/types/stories/callout/callout.stories.d.ts +7 -0
  37. package/dist/types/stories/callout/callout.stories.d.ts.map +1 -1
  38. package/dist/types/stories/general/splitscroll.stories.d.ts +19 -0
  39. package/dist/types/stories/general/splitscroll.stories.d.ts.map +1 -0
  40. package/dist/types/stories/lookbook/lookbook.stories.d.ts +19 -0
  41. package/dist/types/stories/lookbook/lookbook.stories.d.ts.map +1 -0
  42. package/dist/types/tests/splitscroll.test.d.ts +2 -0
  43. package/dist/types/tests/splitscroll.test.d.ts.map +1 -0
  44. package/package.json +5 -5
  45. package/dist/mocks/browser.js +0 -1
  46. package/dist/mocks/handlers.js +0 -206
  47. package/dist/mocks/index.js +0 -1
  48. package/dist/types/mocks/browser.d.ts +0 -1
  49. package/dist/types/mocks/browser.d.ts.map +0 -1
  50. package/dist/types/mocks/handlers.d.ts +0 -2
  51. package/dist/types/mocks/handlers.d.ts.map +0 -1
  52. package/dist/types/mocks/index.d.ts +0 -1
  53. package/dist/types/mocks/index.d.ts.map +0 -1
@@ -841,6 +841,17 @@ async function analyzeSinglePage(url) {
841
841
  console.warn(`Failed to load page: ${url} - no response received`);
842
842
  // Continue with analysis using available data, but mark header-dependent metrics as unavailable
843
843
  }
844
+ // Check Content-Type header - only analyze HTML pages
845
+ const contentType = response?.headers()['content-type'] || '';
846
+ if (!contentType.startsWith('text/html')) {
847
+ return {
848
+ url,
849
+ title: '',
850
+ statusCode: response ? response.status() : 200,
851
+ audits: [],
852
+ crawledAt: new Date().toISOString()
853
+ };
854
+ }
844
855
  // Wait for H1 elements to be rendered (if any) with a short timeout
845
856
  try {
846
857
  await page.waitForSelector('h1', { timeout: 1000 }); // Reduced from 2000 to 1000
@@ -1186,6 +1197,10 @@ export async function performOnSiteSEOAnalysis(baseUrl) {
1186
1197
  for (const pageUrl of pagesToAnalyze) {
1187
1198
  try {
1188
1199
  const pageAnalysis = await analyzeSinglePage(pageUrl);
1200
+ // Skip pages that aren't HTML (no audits)
1201
+ if (pageAnalysis.audits.length === 0) {
1202
+ continue;
1203
+ }
1189
1204
  // Extract and remove schema audits from page-level audits to avoid duplication
1190
1205
  const schemaIds = Object.keys(schemaResults);
1191
1206
  pageAnalysis.audits = pageAnalysis.audits.filter(audit => {
@@ -38,7 +38,8 @@ Callout.propTypes = {
38
38
  imgClick: PropTypes.func,
39
39
  title: PropTypes.string,
40
40
  subtitle: PropTypes.string,
41
- content: PropTypes.string,
41
+ content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
42
+ children: PropTypes.node,
42
43
  buttonText: PropTypes.string,
43
44
  // SmartImage props
44
45
  aboveFold: PropTypes.bool,
@@ -46,13 +47,13 @@ Callout.propTypes = {
46
47
  cloudinaryDomain: PropTypes.string,
47
48
  cloudinaryTransforms: PropTypes.string, */
48
49
  };
49
- export function Callout({ variant = 'default', boxShape = "squircle", layout = "horizontal", direction = 'left', gridColumns = { left: 1, right: 2 }, url, img, imgAlt, imgShape = 'square', imgClick, title, subtitle, content, buttonText, aboveFold,
50
+ export function Callout({ variant = 'default', boxShape = "squircle", layout = "horizontal", direction = 'left', gridColumns = { left: 1, right: 2 }, url, img, imgAlt, imgShape = 'square', imgClick, title, subtitle, content, children, buttonText, aboveFold,
50
51
  /* cloudinaryEnv,
51
52
  cloudinaryDomain,
52
53
  cloudinaryTransforms */ }) {
53
54
  const target = url && url.substring(0, 4).toLowerCase() === 'http' ? '_blank' : '_self';
54
55
  const friendlyTitle = title ? title.toLowerCase().replace(/\s+/g, '-') : undefined;
55
- const body = _jsxs("div", { className: "callout-body", children: [(title) ? _jsx(CalloutHeader, { title: title, url: url, target: target }) : null, (subtitle) ? _jsx("div", { className: "callout-subtitle", children: _jsx("h3", { children: subtitle }) }) : null, content ? _jsx("div", { className: "callout-content", children: _jsx(_Fragment, { children: content }) }) : null, url && buttonText
56
+ const body = _jsxs("div", { className: "callout-body", children: [(title) ? _jsx(CalloutHeader, { title: title, url: url, target: target }) : null, (subtitle) ? _jsx("div", { className: "callout-subtitle", children: _jsx("h3", { children: subtitle }) }) : null, children ? _jsx("div", { className: "callout-content", children: children }) : content ? _jsx("div", { className: "callout-content", children: _jsx(_Fragment, { children: content }) }) : null, url && buttonText
56
57
  ? _jsx(CalloutButton, { title: buttonText, url: url, target: target })
57
58
  : url && title
58
59
  ? _jsx(CalloutButton, { title: title || "", url: url, target: target })
@@ -0,0 +1,117 @@
1
+ import { useEffect, useRef } from 'react';
2
+ /**
3
+ * Custom hook for IntersectionObserver
4
+ *
5
+ * @param callback - Function to call when intersection changes
6
+ * @param options - IntersectionObserver options
7
+ * @returns Ref to attach to the element to observe
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * const elementRef = useIntersectionObserver((entry) => {
12
+ * if (entry.isIntersecting) {
13
+ * console.log('Element is visible!');
14
+ * }
15
+ * }, { threshold: 0.5 });
16
+ *
17
+ * return <div ref={elementRef}>Observed content</div>
18
+ * ```
19
+ */
20
+ export function useIntersectionObserver(callback, options = {}) {
21
+ const elementRef = useRef(null);
22
+ useEffect(() => {
23
+ const element = elementRef.current;
24
+ if (!element)
25
+ return;
26
+ const { root = null, rootMargin = '0px', threshold = 0, disconnectAfterIntersection = false } = options;
27
+ const observer = new IntersectionObserver((entries) => {
28
+ entries.forEach((entry) => {
29
+ callback(entry, observer);
30
+ if (disconnectAfterIntersection && entry.isIntersecting) {
31
+ observer.unobserve(entry.target);
32
+ }
33
+ });
34
+ }, {
35
+ root,
36
+ rootMargin,
37
+ threshold
38
+ });
39
+ observer.observe(element);
40
+ return () => {
41
+ observer.disconnect();
42
+ };
43
+ }, [callback, options.root, options.rootMargin, options.threshold, options.disconnectAfterIntersection]);
44
+ return elementRef;
45
+ }
46
+ /**
47
+ * Utility function to observe multiple elements with the same configuration
48
+ * Useful for observing a list of elements or when you need more control than the hook provides
49
+ *
50
+ * @param selector - CSS selector for elements to observe
51
+ * @param callback - Function to call when intersection changes
52
+ * @param options - IntersectionObserver options
53
+ * @returns Cleanup function to disconnect the observer
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * useEffect(() => {
58
+ * const cleanup = observeIntersection('.fade-in', (entry) => {
59
+ * if (entry.isIntersecting) {
60
+ * entry.target.classList.add('visible');
61
+ * }
62
+ * }, { threshold: 0.1 });
63
+ *
64
+ * return cleanup;
65
+ * }, []);
66
+ * ```
67
+ */
68
+ export function observeIntersection(selector, callback, options = {}) {
69
+ const { root = null, rootMargin = '0px', threshold = 0, disconnectAfterIntersection = false } = options;
70
+ const elements = document.querySelectorAll(selector);
71
+ if (elements.length === 0) {
72
+ return () => { }; // Return no-op cleanup if no elements found
73
+ }
74
+ const observer = new IntersectionObserver((entries) => {
75
+ entries.forEach((entry) => {
76
+ callback(entry, observer);
77
+ if (disconnectAfterIntersection && entry.isIntersecting) {
78
+ observer.unobserve(entry.target);
79
+ }
80
+ });
81
+ }, {
82
+ root,
83
+ rootMargin,
84
+ threshold
85
+ });
86
+ elements.forEach((element) => {
87
+ observer.observe(element);
88
+ });
89
+ // Return cleanup function
90
+ return () => {
91
+ observer.disconnect();
92
+ };
93
+ }
94
+ /**
95
+ * Utility functions for viewport detection
96
+ * These are useful for initial checks before setting up observers
97
+ */
98
+ /**
99
+ * Check if an element is fully in the viewport
100
+ */
101
+ export function isElementInViewport(element) {
102
+ const rect = element.getBoundingClientRect();
103
+ return (rect.top >= 0 &&
104
+ rect.left >= 0 &&
105
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
106
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth));
107
+ }
108
+ /**
109
+ * Check if an element is partially in the viewport
110
+ */
111
+ export function isElementPartiallyInViewport(element) {
112
+ const rect = element.getBoundingClientRect();
113
+ return (rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
114
+ rect.left < (window.innerWidth || document.documentElement.clientWidth) &&
115
+ rect.bottom > 0 &&
116
+ rect.right > 0);
117
+ }
@@ -1,7 +1,14 @@
1
- // import React, { useState, useEffect } from 'react';
2
1
  import PropTypes from "prop-types";
2
+ import { observeIntersection, isElementPartiallyInViewport } from './intersection-observer';
3
3
  import './microinteractions.css';
4
4
  /* ========== MICRO ANIMATIONS ========== */
5
+ /**
6
+ * MicroInteractions handles global site animations and interactions.
7
+ * It is typically called once in a top-level component or effect.
8
+ *
9
+ * @param props - Configuration props for enabling/disabling interactions
10
+ * @returns A cleanup function if scrollfadeElements is used
11
+ */
5
12
  MicroInteractions.propTypes = {
6
13
  buttonring: PropTypes.bool,
7
14
  cartpulse: PropTypes.bool,
@@ -14,10 +21,9 @@ MicroInteractions.propTypes = {
14
21
  scrollfadeElements: PropTypes.string,
15
22
  };
16
23
  export function MicroInteractions(props) {
17
- // const debug = true ;
18
24
  const body = document.body;
19
25
  for (const propName in props) {
20
- if (Object.prototype.hasOwnProperty.call(props, propName)) {
26
+ if (Object.prototype.hasOwnProperty.call(props, propName) && propName !== 'scrollfadeElements') {
21
27
  if (props[propName] === true) {
22
28
  body.classList.add(propName);
23
29
  }
@@ -26,61 +32,44 @@ export function MicroInteractions(props) {
26
32
  }
27
33
  }
28
34
  }
29
- if (props.scrollfadeElements)
30
- ScrollFade(props.scrollfadeElements);
31
- }
32
- function isElementInViewport(el) {
33
- const rect = el.getBoundingClientRect();
34
- return (rect.top >= 0 &&
35
- rect.left >= 0 &&
36
- rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
37
- rect.right <= (window.innerWidth || document.documentElement.clientWidth));
38
- }
39
- function isElementPartiallyInViewport(el) {
40
- const rect = el.getBoundingClientRect();
41
- return (rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
42
- rect.left < (window.innerWidth || document.documentElement.clientWidth) &&
43
- rect.bottom > 0 &&
44
- rect.right > 0);
35
+ if (props.scrollfadeElements) {
36
+ return ScrollFade(props.scrollfadeElements);
37
+ }
45
38
  }
39
+ /**
40
+ * Applies a fade-in animation to elements as they enter the viewport
41
+ * @param elements - CSS selector for elements to animate
42
+ * @returns Cleanup function for the intersection observer
43
+ */
46
44
  function ScrollFade(elements) {
47
- const options = {
48
- root: null, // Observes intersection with the viewport
49
- rootMargin: "0px 0px -100px 0px", // Shrinks the top of the root by 200px
50
- threshold: 0 // Triggers when any part of the element intersects the adjusted root
51
- };
52
- const observer = new IntersectionObserver((entries) => {
53
- entries.forEach((entry) => {
54
- if (entry.isIntersecting) {
55
- // if (entry.intersectionRatio > 0.5) {
56
- // Add the animation class when the element enters the viewport
57
- entry.target.classList.add('scrollfade');
58
- entry.target.classList.remove('hidden');
59
- // Optionally, stop observing once animated if you only want it to animate once
60
- observer.unobserve(entry.target);
61
- }
62
- else {
63
- // Optionally, remove the animation class if you want it to re-animate on re-entry
64
- // entry.target.classList.remove('hidden');
65
- // entry.target.classList.remove('scrollfade');
66
- }
67
- });
68
- }, options);
69
- // Select the elements you want to observe and initially hide them
70
45
  const elementsToAnimate = document.querySelectorAll(elements);
46
+ // Initial state setup
71
47
  elementsToAnimate.forEach((element) => {
72
48
  if (isElementPartiallyInViewport(element)) {
73
- if (element.classList.contains('hidden')) {
74
- element.classList.remove('hidden');
75
- }
76
- if (element.classList.contains('scrollfade')) {
77
- element.classList.remove('scrollfade');
78
- }
49
+ // If already in viewport, make sure it's visible without animation
50
+ element.classList.remove('hidden');
51
+ element.classList.remove('scrollfade');
79
52
  }
80
53
  else {
81
54
  // Apply initial hidden state to elements NOT on the screen
82
- element.classList.add('hidden'); // Apply initial hidden state
83
- observer.observe(element); // Start observing each element
55
+ element.classList.add('hidden');
56
+ }
57
+ });
58
+ // Setup observer for elements not yet visible
59
+ const cleanup = observeIntersection(elements, (entry, observer) => {
60
+ if (entry.isIntersecting) {
61
+ const element = entry.target;
62
+ // Only animate if it was hidden
63
+ if (element.classList.contains('hidden')) {
64
+ element.classList.add('scrollfade');
65
+ element.classList.remove('hidden');
66
+ // Stop observing after animation triggers
67
+ observer.unobserve(element);
68
+ }
84
69
  }
70
+ }, {
71
+ rootMargin: "0px 0px -100px 0px",
72
+ threshold: 0
85
73
  });
74
+ return cleanup;
86
75
  }
@@ -1,23 +1,35 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import PropTypes from 'prop-types';
3
- ServicesSchema.propTypes = {
3
+ const servicesSchemaPropTypes = {
4
+ siteInfo: PropTypes.object,
4
5
  provider: PropTypes.shape({
5
6
  name: PropTypes.string.isRequired,
6
7
  url: PropTypes.string.isRequired,
7
8
  logo: PropTypes.string,
8
9
  telephone: PropTypes.string,
9
10
  email: PropTypes.string,
10
- }).isRequired,
11
+ }),
11
12
  services: PropTypes.arrayOf(PropTypes.shape({
12
13
  name: PropTypes.string.isRequired,
13
14
  description: PropTypes.string.isRequired,
14
15
  url: PropTypes.string,
15
16
  image: PropTypes.string,
16
17
  areaServed: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
17
- })).isRequired,
18
+ })),
18
19
  };
19
20
  export function ServicesSchema(props) {
20
- const { provider, services } = props;
21
+ const siteInfo = props.siteInfo;
22
+ const services = (siteInfo?.services || props.services || []);
23
+ const provider = props.provider || {
24
+ name: siteInfo?.name || '',
25
+ url: siteInfo?.url || '',
26
+ logo: siteInfo?.image,
27
+ telephone: siteInfo?.telephone,
28
+ email: siteInfo?.email
29
+ };
30
+ if (!services.length || !provider.name) {
31
+ return null;
32
+ }
21
33
  const serviceObjects = services.map((service) => ({
22
34
  '@type': 'Service',
23
35
  name: service.name,
@@ -39,4 +51,5 @@ export function ServicesSchema(props) {
39
51
  ...service
40
52
  }) } }, idx))) }));
41
53
  }
54
+ ServicesSchema.propTypes = servicesSchemaPropTypes;
42
55
  export default ServicesSchema;
@@ -12,31 +12,124 @@ WebsiteSchema.propTypes = {
12
12
  name: PropTypes.string,
13
13
  url: PropTypes.string,
14
14
  description: PropTypes.string,
15
+ keywords: PropTypes.string,
16
+ inLanguage: PropTypes.string,
17
+ sameAs: PropTypes.arrayOf(PropTypes.string),
15
18
  potentialAction: PropTypes.shape({
16
19
  '@type': PropTypes.string,
17
20
  target: PropTypes.shape({
18
21
  '@type': PropTypes.string,
19
22
  urlTemplate: PropTypes.string
20
- }),
21
- query: PropTypes.string
23
+ }).isRequired,
24
+ 'query-input': PropTypes.string
22
25
  }),
23
- siteInfo: PropTypes.object // Required siteinfo from parent component
26
+ publisher: PropTypes.shape({
27
+ '@type': PropTypes.string,
28
+ name: PropTypes.string.isRequired,
29
+ url: PropTypes.string,
30
+ logo: PropTypes.shape({
31
+ '@type': PropTypes.string,
32
+ url: PropTypes.string.isRequired,
33
+ width: PropTypes.number,
34
+ height: PropTypes.number
35
+ })
36
+ }),
37
+ copyrightYear: PropTypes.number,
38
+ copyrightHolder: PropTypes.shape({
39
+ '@type': PropTypes.string,
40
+ name: PropTypes.string.isRequired,
41
+ url: PropTypes.string
42
+ }),
43
+ siteInfo: PropTypes.object
24
44
  };
25
45
  export function WebsiteSchema(props) {
26
- // const config = usePixelatedConfig();
27
46
  const siteInfo = props.siteInfo;
28
- // Use props if provided, otherwise fall back to siteInfo
29
47
  const name = props.name || siteInfo?.name;
30
48
  const url = props.url || siteInfo?.url;
49
+ if (!name || !url) {
50
+ return null;
51
+ }
31
52
  const description = props.description || siteInfo?.description;
32
- const potentialAction = props.potentialAction;
53
+ const keywords = props.keywords || siteInfo?.keywords;
54
+ const inLanguage = props.inLanguage || siteInfo?.default_locale;
55
+ const sameAs = props.sameAs || siteInfo?.sameAs;
56
+ const publisher = props.publisher || buildPublisher(siteInfo);
57
+ const potentialAction = props.potentialAction || buildPotentialAction(siteInfo?.potentialAction);
58
+ const copyrightYear = props.copyrightYear ?? siteInfo?.copyrightYear;
59
+ const copyrightHolder = props.copyrightHolder || buildCopyrightHolder(siteInfo);
33
60
  const schemaData = {
34
61
  '@context': 'https://schema.org',
35
62
  '@type': 'WebSite',
36
63
  name,
37
64
  url,
38
65
  ...(description && { description }),
39
- ...(potentialAction && { potentialAction })
66
+ ...(keywords && { keywords }),
67
+ ...(inLanguage && { inLanguage }),
68
+ ...(sameAs && sameAs.length ? { sameAs } : {}),
69
+ ...(publisher && { publisher }),
70
+ ...(potentialAction && { potentialAction }),
71
+ ...(copyrightYear != null && { copyrightYear }),
72
+ ...(copyrightHolder && { copyrightHolder })
40
73
  };
41
74
  return (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(schemaData) } }));
42
75
  }
76
+ function buildPublisher(siteInfo) {
77
+ if (!siteInfo) {
78
+ return undefined;
79
+ }
80
+ if (!siteInfo.name) {
81
+ return undefined;
82
+ }
83
+ const logoUrl = siteInfo.image;
84
+ const logoWidth = parseDimension(siteInfo.image_width);
85
+ const logoHeight = parseDimension(siteInfo.image_height);
86
+ const logo = logoUrl
87
+ ? {
88
+ '@type': 'ImageObject',
89
+ url: logoUrl,
90
+ ...(logoWidth !== undefined && { width: logoWidth }),
91
+ ...(logoHeight !== undefined && { height: logoHeight })
92
+ }
93
+ : undefined;
94
+ return {
95
+ '@type': siteInfo.publisherType || 'Organization',
96
+ name: siteInfo.name,
97
+ ...(siteInfo.url && { url: siteInfo.url }),
98
+ ...(logo && { logo })
99
+ };
100
+ }
101
+ function buildCopyrightHolder(siteInfo) {
102
+ if (!siteInfo?.name) {
103
+ return undefined;
104
+ }
105
+ const holderType = siteInfo.publisherType || 'Organization';
106
+ return {
107
+ '@type': holderType,
108
+ name: siteInfo.name,
109
+ ...(siteInfo.url && { url: siteInfo.url })
110
+ };
111
+ }
112
+ function buildPotentialAction(action) {
113
+ if (!action || !action.target) {
114
+ return undefined;
115
+ }
116
+ const queryInput = action['query-input'] ?? action.queryInput;
117
+ return {
118
+ '@type': action['@type'] ?? 'SearchAction',
119
+ target: {
120
+ '@type': 'EntryPoint',
121
+ urlTemplate: action.target
122
+ },
123
+ ...(queryInput && { 'query-input': queryInput })
124
+ };
125
+ }
126
+ function parseDimension(value) {
127
+ if (typeof value === 'number') {
128
+ return value;
129
+ }
130
+ if (!value) {
131
+ return undefined;
132
+ }
133
+ const parsed = Number(value);
134
+ return Number.isNaN(parsed) ? undefined : parsed;
135
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * SplitScroll Styles
3
+ *
4
+ * Provides the sticky image behavior and layered parallax effect
5
+ * for the SplitScroll component
6
+ */
7
+
8
+ .splitscroll-container {
9
+ position: relative;
10
+ width: 100%;
11
+ }
12
+
13
+ .splitscroll-section {
14
+ position: relative;
15
+ min-height: 100vh;
16
+ }
17
+
18
+ /* Split variant callout adjustments for splitscroll */
19
+ .splitscroll-section .callout.split {
20
+ min-height: 100vh;
21
+ display: grid;
22
+ grid-template-columns: 1fr 1fr;
23
+ gap: 0;
24
+ }
25
+
26
+ /* Left side - sticky image */
27
+ .splitscroll-section .callout.split .callout-image {
28
+ position: sticky;
29
+ top: 0;
30
+ height: 100vh;
31
+ overflow: hidden;
32
+ z-index: 1;
33
+ transition: opacity 0.3s ease;
34
+ width: 50vw;
35
+
36
+ }
37
+
38
+ /* Layer images on top of each other */
39
+ .splitscroll-section .callout-image {
40
+ z-index: calc(var(--section-index, 0) + 1);
41
+ }
42
+
43
+ /* Active section has highest z-index */
44
+ .splitscroll-section.active .callout-image {
45
+ z-index: 100;
46
+ opacity: 1;
47
+ }
48
+
49
+ /* Non-active sections fade slightly */
50
+ .splitscroll-section:not(.active) .callout-image {
51
+ opacity: 0.95;
52
+ }
53
+
54
+ /* Right side - scrolling content */
55
+ .splitscroll-section .callout.split .callout-body {
56
+ position: relative;
57
+ padding: 4rem 2rem;
58
+ min-height: 100vh;
59
+ display: flex;
60
+ flex-direction: column;
61
+ justify-content: center;
62
+ z-index: 2;
63
+ width: 50vw;
64
+
65
+ }
66
+
67
+ /* Image fills container and is centered */
68
+ .splitscroll-section .callout-image img {
69
+ width: 100%;
70
+ height: 100%;
71
+ object-fit: cover;
72
+ object-position: center;
73
+ }
74
+
75
+ /* Smooth transitions for section changes */
76
+ .splitscroll-section {
77
+ transition: transform 0.3s ease;
78
+ }
79
+
80
+ /* Responsive behavior */
81
+ @media (max-width: 768px) {
82
+ .splitscroll-section .callout.split {
83
+ grid-template-columns: 1fr;
84
+ grid-template-rows: 50vh auto;
85
+ min-height: auto;
86
+ }
87
+
88
+ .splitscroll-section .callout.split .callout-image {
89
+ position: relative;
90
+ height: 50vh;
91
+ }
92
+
93
+ .splitscroll-section .callout.split .callout-body {
94
+ min-height: 50vh;
95
+ padding: 2rem 1rem;
96
+ }
97
+ }
98
+
99
+ /* Optional: Add subtle parallax effect to content */
100
+ @media (prefers-reduced-motion: no-preference) {
101
+ .splitscroll-section.active .callout-body {
102
+ animation: fadeInUp 0.6s ease forwards;
103
+ }
104
+
105
+ @keyframes fadeInUp {
106
+ from {
107
+ opacity: 0.8;
108
+ transform: translateY(20px);
109
+ }
110
+ to {
111
+ opacity: 1;
112
+ transform: translateY(0);
113
+ }
114
+ }
115
+ }
116
+
117
+ /* Respect reduced motion preferences */
118
+ @media (prefers-reduced-motion: reduce) {
119
+ .splitscroll-section,
120
+ .splitscroll-section .callout-image,
121
+ .splitscroll-section .callout-body {
122
+ transition: none;
123
+ animation: none;
124
+ }
125
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import React, { Children, useState, useEffect } from 'react';
4
+ import PropTypes from 'prop-types';
5
+ import { Callout } from './callout';
6
+ import { observeIntersection } from './intersection-observer';
7
+ import './splitscroll.css';
8
+ /**
9
+ * SplitScroll - A scrolling split-page layout with sticky images
10
+ *
11
+ * Creates a splitscroll-style layout where the left side shows sticky images
12
+ * that layer over each other as you scroll, while the right side contains
13
+ * scrolling content sections.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <SplitScroll>
18
+ * <SplitScroll.Section img="/image1.jpg" title="Section 1">
19
+ * <YourContent />
20
+ * </SplitScroll.Section>
21
+ * <SplitScroll.Section img="/image2.jpg" title="Section 2">
22
+ * <MoreContent />
23
+ * </SplitScroll.Section>
24
+ * </SplitScroll>
25
+ * ```
26
+ */
27
+ SplitScroll.propTypes = {
28
+ children: PropTypes.node.isRequired,
29
+ };
30
+ export function SplitScroll({ children }) {
31
+ const [activeSectionIndex, setActiveSectionIndex] = useState(0);
32
+ const childArray = Children.toArray(children);
33
+ const sectionCount = childArray.length;
34
+ useEffect(() => {
35
+ // Set up intersection observers for each section
36
+ const cleanup = observeIntersection('.splitscroll-section', (entry) => {
37
+ if (entry.isIntersecting) {
38
+ const index = parseInt(entry.target.getAttribute('data-section-index') || '0', 10);
39
+ setActiveSectionIndex(index);
40
+ }
41
+ }, {
42
+ rootMargin: '-20% 0px -60% 0px', // Trigger when section is 20% from top
43
+ threshold: 0
44
+ });
45
+ return cleanup;
46
+ }, [sectionCount]);
47
+ // Clone children and add props for active state and index
48
+ const enhancedChildren = Children.map(children, (child, index) => {
49
+ if (React.isValidElement(child)) {
50
+ const additionalProps = {
51
+ isActive: index === activeSectionIndex,
52
+ sectionIndex: index,
53
+ totalSections: sectionCount
54
+ };
55
+ return React.cloneElement(child, additionalProps);
56
+ }
57
+ return child;
58
+ });
59
+ return (_jsx("div", { className: "splitscroll-container", children: enhancedChildren }));
60
+ }
61
+ /**
62
+ * SplitScroll.Section - Individual section within a SplitScroll
63
+ *
64
+ * A facade for the Callout component with variant="split" preset.
65
+ * Automatically configured for the splitscroll layout.
66
+ */
67
+ const splitscrollSectionPropTypes = {
68
+ img: PropTypes.string.isRequired,
69
+ imgAlt: PropTypes.string,
70
+ imgShape: PropTypes.oneOf(['square', 'bevel', 'squircle', 'round']),
71
+ title: PropTypes.string,
72
+ subtitle: PropTypes.string,
73
+ url: PropTypes.string,
74
+ buttonText: PropTypes.string,
75
+ children: PropTypes.node,
76
+ aboveFold: PropTypes.bool,
77
+ // Internal props added by SplitScroll parent
78
+ isActive: PropTypes.bool,
79
+ sectionIndex: PropTypes.number,
80
+ totalSections: PropTypes.number,
81
+ };
82
+ const SplitScrollSectionComponent = function SplitScrollSection({ img, imgAlt, imgShape = 'square', title, subtitle, url, buttonText, children, aboveFold, isActive, sectionIndex, totalSections, }) {
83
+ return (_jsx("div", { className: `splitscroll-section ${isActive ? 'active' : ''}`, "data-section-index": sectionIndex, style: {
84
+ '--section-index': sectionIndex,
85
+ '--total-sections': totalSections
86
+ }, children: _jsx(Callout, { variant: "split", img: img, imgAlt: imgAlt, imgShape: imgShape, title: title, subtitle: subtitle, url: url, buttonText: buttonText, aboveFold: aboveFold ?? (sectionIndex === 0), children: children }) }));
87
+ };
88
+ SplitScrollSectionComponent.propTypes = splitscrollSectionPropTypes;
89
+ SplitScroll.Section = SplitScrollSectionComponent;