overlapping-cards-scroll 0.1.6 → 0.1.8

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.
@@ -25,15 +25,24 @@ export interface CardItem {
25
25
  jsx: ReactElement;
26
26
  }
27
27
 
28
+ export type TabsPositionSide = "top" | "bottom" | "left" | "right";
29
+ export type TabsPositionAlign = "start" | "center" | "end";
30
+
31
+ export type TabsPosition =
32
+ | "top-left" | "top-center" | "top-right"
33
+ | "bottom-left" | "bottom-center" | "bottom-right"
34
+ | "left-top" | "left-center" | "left-bottom"
35
+ | "right-top" | "right-center" | "right-bottom"
36
+ | "above" | "below";
37
+
28
38
  export interface OverlappingCardsScrollTabProps {
29
39
  name: string;
30
40
  index: number;
31
- position: "above" | "below";
41
+ position: TabsPositionSide;
42
+ align: TabsPositionAlign;
32
43
  isPrincipal: boolean;
33
44
  influence: number;
34
- animate: {
35
- opacity: number;
36
- };
45
+ animate: { opacity: number };
37
46
  className: string;
38
47
  style: CSSProperties;
39
48
  ariaLabel: string;
@@ -43,7 +52,8 @@ export interface OverlappingCardsScrollTabProps {
43
52
 
44
53
  export interface OverlappingCardsScrollTabsContainerProps {
45
54
  children: ReactNode;
46
- position: "above" | "below";
55
+ position: TabsPositionSide;
56
+ align: TabsPositionAlign;
47
57
  className: string;
48
58
  style: CSSProperties;
49
59
  ariaLabel: string;
@@ -72,7 +82,7 @@ type SharedProps = {
72
82
  focusTransitionDuration?: number;
73
83
  ariaLabel?: string;
74
84
  showTabs?: boolean;
75
- tabsPosition?: "above" | "below";
85
+ tabsPosition?: TabsPosition;
76
86
  tabsOffset?: number | string;
77
87
  tabsBehavior?: "smooth" | "auto";
78
88
  tabsClassName?: string;
@@ -101,10 +111,33 @@ const PAGE_DOT_POSITIONS = new Set(["above", "below", "overlay"]);
101
111
  const normalizePageDotsPosition = (value) =>
102
112
  PAGE_DOT_POSITIONS.has(value) ? value : "below";
103
113
 
104
- const TAB_POSITIONS = new Set(["above", "below"]);
114
+ interface ParsedTabsPosition {
115
+ side: TabsPositionSide;
116
+ align: TabsPositionAlign;
117
+ orientation: "horizontal" | "vertical";
118
+ }
105
119
 
106
- const normalizeTabsPosition = (value) =>
107
- TAB_POSITIONS.has(value) ? value : "above";
120
+ const TABS_POSITION_MAP: Record<string, ParsedTabsPosition> = {
121
+ "top-left": { side: "top", align: "start", orientation: "horizontal" },
122
+ "top-center": { side: "top", align: "center", orientation: "horizontal" },
123
+ "top-right": { side: "top", align: "end", orientation: "horizontal" },
124
+ "bottom-left": { side: "bottom", align: "start", orientation: "horizontal" },
125
+ "bottom-center": { side: "bottom", align: "center", orientation: "horizontal" },
126
+ "bottom-right": { side: "bottom", align: "end", orientation: "horizontal" },
127
+ "left-top": { side: "left", align: "start", orientation: "vertical" },
128
+ "left-center": { side: "left", align: "center", orientation: "vertical" },
129
+ "left-bottom": { side: "left", align: "end", orientation: "vertical" },
130
+ "right-top": { side: "right", align: "start", orientation: "vertical" },
131
+ "right-center": { side: "right", align: "center", orientation: "vertical" },
132
+ "right-bottom": { side: "right", align: "end", orientation: "vertical" },
133
+ "above": { side: "top", align: "center", orientation: "horizontal" },
134
+ "below": { side: "bottom", align: "center", orientation: "horizontal" },
135
+ };
136
+
137
+ const DEFAULT_TABS_POSITION: ParsedTabsPosition = { side: "top", align: "center", orientation: "horizontal" };
138
+
139
+ const parseTabsPosition = (value: string | undefined): ParsedTabsPosition =>
140
+ (value && TABS_POSITION_MAP[value]) || DEFAULT_TABS_POSITION;
108
141
 
109
142
  // Persist across HMR so remount gets a valid fallback instead of 1
110
143
  let lastKnownViewportWidth = 1;
@@ -328,6 +361,7 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
328
361
  const cardCount = cards.length;
329
362
 
330
363
  const containerRef = useRef(null);
364
+ const stageRef = useRef(null);
331
365
  const scrollRef = useRef(null);
332
366
  const touchStateRef = useRef(null);
333
367
  const snapTimeoutRef = useRef(null);
@@ -358,9 +392,9 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
358
392
  }, [clearFocusTransitionTimeout]);
359
393
 
360
394
  useEffect(() => {
361
- const containerElement = containerRef.current;
395
+ const stageElement = stageRef.current;
362
396
  const scrollElement = scrollRef.current;
363
- if (!containerElement || !scrollElement) {
397
+ if (!stageElement || !scrollElement) {
364
398
  return undefined;
365
399
  }
366
400
 
@@ -384,8 +418,8 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
384
418
  syncScroll();
385
419
  });
386
420
 
387
- resizeObserver.observe(containerElement);
388
- applyWidth(containerElement.getBoundingClientRect().width ?? 0);
421
+ resizeObserver.observe(stageElement);
422
+ applyWidth(stageElement.getBoundingClientRect().width ?? 0);
389
423
  syncScroll();
390
424
 
391
425
  scrollElement.addEventListener("scroll", syncScroll, { passive: true });
@@ -770,8 +804,6 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
770
804
  touchStateRef.current = null;
771
805
  };
772
806
 
773
- const stageRef = useRef(null);
774
-
775
807
  useEffect(() => {
776
808
  const stageElement = stageRef.current;
777
809
  if (!stageElement) {
@@ -784,15 +816,21 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
784
816
  };
785
817
  }, [handleWheel]);
786
818
 
787
- const containerClassName = className
788
- ? `overlapping-cards-scroll ${className}`
789
- : "overlapping-cards-scroll";
790
819
  const resolvedPageDotsPosition = normalizePageDotsPosition(pageDotsPosition);
791
820
  const showNavigationDots = showPageDots && cardCount > 1;
792
821
 
793
- const resolvedTabsPosition = normalizeTabsPosition(tabsPosition);
822
+ const parsedTabsPosition = parseTabsPosition(tabsPosition);
823
+ const isVerticalTabs = parsedTabsPosition.orientation === "vertical";
794
824
  const showNavigationTabs = showTabs && cardCount > 1 && cardNames !== null;
795
825
 
826
+ const containerClassName = [
827
+ "overlapping-cards-scroll",
828
+ isVerticalTabs ? "overlapping-cards-scroll--vertical-tabs" : "",
829
+ className,
830
+ ]
831
+ .filter(Boolean)
832
+ .join(" ");
833
+
796
834
  useEffect(() => {
797
835
  if (showTabs && cardNames === null) {
798
836
  console.warn(
@@ -801,24 +839,44 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
801
839
  }
802
840
  }, [showTabs, cardNames]);
803
841
 
804
- const renderTabs = (position: "above" | "below") => {
842
+ const renderTabs = () => {
805
843
  if (!showNavigationTabs || cardNames === null) {
806
844
  return null;
807
845
  }
808
846
 
809
- const containerClassName = tabsClassName
810
- ? `ocs-tabs ocs-tabs--${position} ${tabsClassName}`
811
- : `ocs-tabs ocs-tabs--${position}`;
812
-
813
- const containerStyle =
814
- position === "above"
815
- ? { marginBottom: toCssDimension(tabsOffset) }
816
- : { marginTop: toCssDimension(tabsOffset) };
847
+ const { side, align, orientation } = parsedTabsPosition;
848
+ const isVertical = orientation === "vertical";
849
+
850
+ const alignClass =
851
+ align === "start"
852
+ ? "ocs-tabs--align-start"
853
+ : align === "end"
854
+ ? "ocs-tabs--align-end"
855
+ : "ocs-tabs--align-center";
856
+
857
+ const orientationClass = isVertical ? "ocs-tabs--vertical" : "";
858
+
859
+ const classNames = [
860
+ "ocs-tabs",
861
+ `ocs-tabs--${side}`,
862
+ alignClass,
863
+ orientationClass,
864
+ tabsClassName,
865
+ ]
866
+ .filter(Boolean)
867
+ .join(" ");
868
+
869
+ const containerStyle: CSSProperties = {};
870
+ if (side === "top") containerStyle.marginBottom = toCssDimension(tabsOffset);
871
+ else if (side === "bottom") containerStyle.marginTop = toCssDimension(tabsOffset);
872
+ else if (side === "left") containerStyle.marginRight = toCssDimension(tabsOffset);
873
+ else if (side === "right") containerStyle.marginLeft = toCssDimension(tabsOffset);
817
874
 
818
875
  return (
819
876
  <TabsContainerComponent
820
- position={position}
821
- className={containerClassName}
877
+ position={side}
878
+ align={align}
879
+ className={classNames}
822
880
  style={containerStyle}
823
881
  ariaLabel="Card tabs"
824
882
  cardNames={cardNames}
@@ -828,18 +886,17 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
828
886
  {cardNames.map((name, index) => {
829
887
  const influence = clamp(1 - Math.abs(progress - index), 0, 1);
830
888
  const isPrincipal = influence > 0.98;
831
- const animate = {
832
- opacity: 0.45 + influence * 0.55,
833
- };
889
+ const animate = { opacity: 0.45 + influence * 0.55 };
834
890
  const className = isPrincipal ? "ocs-tab ocs-tab--active" : "ocs-tab";
835
891
  const style = { opacity: animate.opacity };
836
892
 
837
893
  return (
838
894
  <TabsComponent
839
- key={`ocs-tab-${position}-${index}`}
895
+ key={`ocs-tab-${side}-${index}`}
840
896
  name={name}
841
897
  index={index}
842
- position={position}
898
+ position={side}
899
+ align={align}
843
900
  isPrincipal={isPrincipal}
844
901
  influence={influence}
845
902
  animate={animate}
@@ -860,160 +917,120 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
860
917
  );
861
918
  };
862
919
 
863
- return (
864
- <OverlappingCardsScrollControllerContext.Provider
865
- value={controllerContextValue}
866
- >
867
- <section
868
- className={containerClassName}
869
- aria-label={ariaLabel}
870
- ref={containerRef}
871
- >
872
- {resolvedTabsPosition === "above" ? renderTabs("above") : null}
873
- {showNavigationDots && resolvedPageDotsPosition === "above" ? (
874
- <nav
875
- className={
876
- pageDotsClassName
877
- ? `ocs-page-dots ocs-page-dots--above ${pageDotsClassName}`
878
- : "ocs-page-dots ocs-page-dots--above"
879
- }
880
- style={{ marginBottom: toCssDimension(pageDotsOffset) }}
881
- aria-label="Card pages"
882
- >
883
- {cards.map((_, index) => {
884
- const influence = clamp(1 - Math.abs(progress - index), 0, 1);
885
- const opacity = 0.25 + influence * 0.75;
886
- const scale = 0.9 + influence * 0.22;
920
+ const tabsBeforeStage =
921
+ parsedTabsPosition.side === "top" || parsedTabsPosition.side === "left";
922
+
923
+ const stageAndDots = (
924
+ <>
925
+ {showNavigationDots && resolvedPageDotsPosition === "above" ? (
926
+ <nav
927
+ className={
928
+ pageDotsClassName
929
+ ? `ocs-page-dots ocs-page-dots--above ${pageDotsClassName}`
930
+ : "ocs-page-dots ocs-page-dots--above"
931
+ }
932
+ style={{ marginBottom: toCssDimension(pageDotsOffset) }}
933
+ aria-label="Card pages"
934
+ >
935
+ {cards.map((_, index) => {
936
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1);
937
+ const opacity = 0.25 + influence * 0.75;
938
+ const scale = 0.9 + influence * 0.22;
939
+
940
+ return (
941
+ <button
942
+ key={`ocs-page-dot-above-${index}`}
943
+ type="button"
944
+ className="ocs-page-dot"
945
+ aria-label={`Go to card ${index + 1}`}
946
+ aria-current={influence > 0.98 ? "page" : undefined}
947
+ onClick={() =>
948
+ focusCard(index, {
949
+ behavior: pageDotsBehavior,
950
+ transitionMode: "swoop",
951
+ })
952
+ }
953
+ style={{ opacity, transform: `scale(${scale})` }}
954
+ />
955
+ );
956
+ })}
957
+ </nav>
958
+ ) : null}
959
+ <div className="ocs-stage-frame">
960
+ <div
961
+ className="ocs-stage"
962
+ ref={stageRef}
963
+ style={{
964
+ minHeight: toCssDimension(cardHeight),
965
+ }}
966
+ onTouchStart={handleTouchStart}
967
+ onTouchMove={handleTouchMove}
968
+ onTouchEnd={handleTouchEnd}
969
+ onTouchCancel={handleTouchEnd}
970
+ >
971
+ <div className="ocs-track">
972
+ {cards.map((card, index) => {
973
+ const cardX = resolveCardX(
974
+ index,
975
+ activeIndex,
976
+ transitionProgress,
977
+ layout,
978
+ );
887
979
 
888
980
  return (
889
- <button
890
- key={`ocs-page-dot-above-${index}`}
891
- type="button"
892
- className="ocs-page-dot"
893
- aria-label={`Go to card ${index + 1}`}
894
- aria-current={influence > 0.98 ? "page" : undefined}
895
- onClick={() =>
896
- focusCard(index, {
897
- behavior: pageDotsBehavior,
898
- transitionMode: "swoop",
899
- })
981
+ <div
982
+ key={card.key ?? `ocs-card-${index}`}
983
+ className={
984
+ cardContainerClassName
985
+ ? `${focusTransition ? "ocs-card ocs-card--focus-transition" : "ocs-card"} ${cardContainerClassName}`
986
+ : focusTransition
987
+ ? "ocs-card ocs-card--focus-transition"
988
+ : "ocs-card"
900
989
  }
901
- style={{ opacity, transform: `scale(${scale})` }}
902
- />
903
- );
904
- })}
905
- </nav>
906
- ) : null}
907
- <div className="ocs-stage-frame">
908
- <div
909
- className="ocs-stage"
910
- ref={stageRef}
911
- style={{
912
- minHeight: toCssDimension(cardHeight),
913
- }}
914
- onTouchStart={handleTouchStart}
915
- onTouchMove={handleTouchMove}
916
- onTouchEnd={handleTouchEnd}
917
- onTouchCancel={handleTouchEnd}
918
- >
919
- <div className="ocs-track">
920
- {cards.map((card, index) => {
921
- const cardX = resolveCardX(
922
- index,
923
- activeIndex,
924
- transitionProgress,
925
- layout,
926
- );
927
-
928
- return (
990
+ style={{
991
+ width: `${layout.cardWidth}px`,
992
+ transform: `translate3d(${cardX}px, 0, 0)`,
993
+ transitionDuration: focusTransition
994
+ ? `${focusTransition.duration}ms`
995
+ : undefined,
996
+ ...cardContainerStyle,
997
+ pointerEvents: "none",
998
+ }}
999
+ >
929
1000
  <div
930
- key={card.key ?? `ocs-card-${index}`}
931
- className={
932
- cardContainerClassName
933
- ? `${focusTransition ? "ocs-card ocs-card--focus-transition" : "ocs-card"} ${cardContainerClassName}`
934
- : focusTransition
935
- ? "ocs-card ocs-card--focus-transition"
936
- : "ocs-card"
937
- }
938
1001
  style={{
939
- width: `${layout.cardWidth}px`,
940
- transform: `translate3d(${cardX}px, 0, 0)`,
941
- transitionDuration: focusTransition
942
- ? `${focusTransition.duration}ms`
943
- : undefined,
944
- ...cardContainerStyle,
945
- pointerEvents: "none",
1002
+ pointerEvents: "auto",
1003
+ display: "flex",
1004
+ flexDirection: "column",
946
1005
  }}
947
1006
  >
948
- <div
949
- style={{
950
- pointerEvents: "auto",
951
- display: "flex",
952
- flexDirection: "column",
953
- }}
1007
+ <OverlappingCardsScrollCardIndexContext.Provider
1008
+ value={index}
954
1009
  >
955
- <OverlappingCardsScrollCardIndexContext.Provider
956
- value={index}
957
- >
958
- {card}
959
- </OverlappingCardsScrollCardIndexContext.Provider>
960
- </div>
1010
+ {card}
1011
+ </OverlappingCardsScrollCardIndexContext.Provider>
961
1012
  </div>
962
- );
963
- })}
964
- </div>
965
- <div className="ocs-scroll-region" ref={scrollRef}>
966
- <div
967
- className="ocs-scroll-spacer"
968
- style={{
969
- width: `${layout.trackWidth}px`,
970
- }}
971
- />
972
- </div>
1013
+ </div>
1014
+ );
1015
+ })}
1016
+ </div>
1017
+ <div className="ocs-scroll-region" ref={scrollRef}>
1018
+ <div
1019
+ className="ocs-scroll-spacer"
1020
+ style={{
1021
+ width: `${layout.trackWidth}px`,
1022
+ }}
1023
+ />
973
1024
  </div>
974
- {showNavigationDots && resolvedPageDotsPosition === "overlay" ? (
975
- <nav
976
- className={
977
- pageDotsClassName
978
- ? `ocs-page-dots ocs-page-dots--overlay ${pageDotsClassName}`
979
- : "ocs-page-dots ocs-page-dots--overlay"
980
- }
981
- style={{ bottom: toCssDimension(pageDotsOffset) }}
982
- aria-label="Card pages"
983
- >
984
- {cards.map((_, index) => {
985
- const influence = clamp(1 - Math.abs(progress - index), 0, 1);
986
- const opacity = 0.25 + influence * 0.75;
987
- const scale = 0.9 + influence * 0.22;
988
-
989
- return (
990
- <button
991
- key={`ocs-page-dot-overlay-${index}`}
992
- type="button"
993
- className="ocs-page-dot"
994
- aria-label={`Go to card ${index + 1}`}
995
- aria-current={influence > 0.98 ? "page" : undefined}
996
- onClick={() =>
997
- focusCard(index, {
998
- behavior: pageDotsBehavior,
999
- transitionMode: "swoop",
1000
- })
1001
- }
1002
- style={{ opacity, transform: `scale(${scale})` }}
1003
- />
1004
- );
1005
- })}
1006
- </nav>
1007
- ) : null}
1008
1025
  </div>
1009
- {showNavigationDots && resolvedPageDotsPosition === "below" ? (
1026
+ {showNavigationDots && resolvedPageDotsPosition === "overlay" ? (
1010
1027
  <nav
1011
1028
  className={
1012
1029
  pageDotsClassName
1013
- ? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
1014
- : "ocs-page-dots ocs-page-dots--below"
1030
+ ? `ocs-page-dots ocs-page-dots--overlay ${pageDotsClassName}`
1031
+ : "ocs-page-dots ocs-page-dots--overlay"
1015
1032
  }
1016
- style={{ marginTop: toCssDimension(pageDotsOffset) }}
1033
+ style={{ bottom: toCssDimension(pageDotsOffset) }}
1017
1034
  aria-label="Card pages"
1018
1035
  >
1019
1036
  {cards.map((_, index) => {
@@ -1023,7 +1040,7 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
1023
1040
 
1024
1041
  return (
1025
1042
  <button
1026
- key={`ocs-page-dot-below-${index}`}
1043
+ key={`ocs-page-dot-overlay-${index}`}
1027
1044
  type="button"
1028
1045
  className="ocs-page-dot"
1029
1046
  aria-label={`Go to card ${index + 1}`}
@@ -1040,7 +1057,62 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
1040
1057
  })}
1041
1058
  </nav>
1042
1059
  ) : null}
1043
- {resolvedTabsPosition === "below" ? renderTabs("below") : null}
1060
+ </div>
1061
+ {showNavigationDots && resolvedPageDotsPosition === "below" ? (
1062
+ <nav
1063
+ className={
1064
+ pageDotsClassName
1065
+ ? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
1066
+ : "ocs-page-dots ocs-page-dots--below"
1067
+ }
1068
+ style={{ marginTop: toCssDimension(pageDotsOffset) }}
1069
+ aria-label="Card pages"
1070
+ >
1071
+ {cards.map((_, index) => {
1072
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1);
1073
+ const opacity = 0.25 + influence * 0.75;
1074
+ const scale = 0.9 + influence * 0.22;
1075
+
1076
+ return (
1077
+ <button
1078
+ key={`ocs-page-dot-below-${index}`}
1079
+ type="button"
1080
+ className="ocs-page-dot"
1081
+ aria-label={`Go to card ${index + 1}`}
1082
+ aria-current={influence > 0.98 ? "page" : undefined}
1083
+ onClick={() =>
1084
+ focusCard(index, {
1085
+ behavior: pageDotsBehavior,
1086
+ transitionMode: "swoop",
1087
+ })
1088
+ }
1089
+ style={{ opacity, transform: `scale(${scale})` }}
1090
+ />
1091
+ );
1092
+ })}
1093
+ </nav>
1094
+ ) : null}
1095
+ </>
1096
+ );
1097
+
1098
+ const stageContent = isVerticalTabs ? (
1099
+ <div className="ocs-main-column">{stageAndDots}</div>
1100
+ ) : (
1101
+ stageAndDots
1102
+ );
1103
+
1104
+ return (
1105
+ <OverlappingCardsScrollControllerContext.Provider
1106
+ value={controllerContextValue}
1107
+ >
1108
+ <section
1109
+ className={containerClassName}
1110
+ aria-label={ariaLabel}
1111
+ ref={containerRef}
1112
+ >
1113
+ {tabsBeforeStage ? renderTabs() : null}
1114
+ {stageContent}
1115
+ {!tabsBeforeStage ? renderTabs() : null}
1044
1116
  </section>
1045
1117
  </OverlappingCardsScrollControllerContext.Provider>
1046
1118
  );
package/src/lib/index.ts CHANGED
@@ -5,6 +5,9 @@ export {
5
5
 
6
6
  export type {
7
7
  CardItem,
8
+ TabsPosition,
9
+ TabsPositionSide,
10
+ TabsPositionAlign,
8
11
  OverlappingCardsScrollTabProps,
9
12
  OverlappingCardsScrollTabsContainerProps,
10
13
  } from "./OverlappingCardsScroll";