overlapping-cards-scroll 0.1.6 → 0.1.7

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;
@@ -784,15 +817,21 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
784
817
  };
785
818
  }, [handleWheel]);
786
819
 
787
- const containerClassName = className
788
- ? `overlapping-cards-scroll ${className}`
789
- : "overlapping-cards-scroll";
790
820
  const resolvedPageDotsPosition = normalizePageDotsPosition(pageDotsPosition);
791
821
  const showNavigationDots = showPageDots && cardCount > 1;
792
822
 
793
- const resolvedTabsPosition = normalizeTabsPosition(tabsPosition);
823
+ const parsedTabsPosition = parseTabsPosition(tabsPosition);
824
+ const isVerticalTabs = parsedTabsPosition.orientation === "vertical";
794
825
  const showNavigationTabs = showTabs && cardCount > 1 && cardNames !== null;
795
826
 
827
+ const containerClassName = [
828
+ "overlapping-cards-scroll",
829
+ isVerticalTabs ? "overlapping-cards-scroll--vertical-tabs" : "",
830
+ className,
831
+ ]
832
+ .filter(Boolean)
833
+ .join(" ");
834
+
796
835
  useEffect(() => {
797
836
  if (showTabs && cardNames === null) {
798
837
  console.warn(
@@ -801,24 +840,44 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
801
840
  }
802
841
  }, [showTabs, cardNames]);
803
842
 
804
- const renderTabs = (position: "above" | "below") => {
843
+ const renderTabs = () => {
805
844
  if (!showNavigationTabs || cardNames === null) {
806
845
  return null;
807
846
  }
808
847
 
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) };
848
+ const { side, align, orientation } = parsedTabsPosition;
849
+ const isVertical = orientation === "vertical";
850
+
851
+ const alignClass =
852
+ align === "start"
853
+ ? "ocs-tabs--align-start"
854
+ : align === "end"
855
+ ? "ocs-tabs--align-end"
856
+ : "ocs-tabs--align-center";
857
+
858
+ const orientationClass = isVertical ? "ocs-tabs--vertical" : "";
859
+
860
+ const classNames = [
861
+ "ocs-tabs",
862
+ `ocs-tabs--${side}`,
863
+ alignClass,
864
+ orientationClass,
865
+ tabsClassName,
866
+ ]
867
+ .filter(Boolean)
868
+ .join(" ");
869
+
870
+ const containerStyle: CSSProperties = {};
871
+ if (side === "top") containerStyle.marginBottom = toCssDimension(tabsOffset);
872
+ else if (side === "bottom") containerStyle.marginTop = toCssDimension(tabsOffset);
873
+ else if (side === "left") containerStyle.marginRight = toCssDimension(tabsOffset);
874
+ else if (side === "right") containerStyle.marginLeft = toCssDimension(tabsOffset);
817
875
 
818
876
  return (
819
877
  <TabsContainerComponent
820
- position={position}
821
- className={containerClassName}
878
+ position={side}
879
+ align={align}
880
+ className={classNames}
822
881
  style={containerStyle}
823
882
  ariaLabel="Card tabs"
824
883
  cardNames={cardNames}
@@ -828,18 +887,17 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
828
887
  {cardNames.map((name, index) => {
829
888
  const influence = clamp(1 - Math.abs(progress - index), 0, 1);
830
889
  const isPrincipal = influence > 0.98;
831
- const animate = {
832
- opacity: 0.45 + influence * 0.55,
833
- };
890
+ const animate = { opacity: 0.45 + influence * 0.55 };
834
891
  const className = isPrincipal ? "ocs-tab ocs-tab--active" : "ocs-tab";
835
892
  const style = { opacity: animate.opacity };
836
893
 
837
894
  return (
838
895
  <TabsComponent
839
- key={`ocs-tab-${position}-${index}`}
896
+ key={`ocs-tab-${side}-${index}`}
840
897
  name={name}
841
898
  index={index}
842
- position={position}
899
+ position={side}
900
+ align={align}
843
901
  isPrincipal={isPrincipal}
844
902
  influence={influence}
845
903
  animate={animate}
@@ -860,160 +918,120 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
860
918
  );
861
919
  };
862
920
 
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;
921
+ const tabsBeforeStage =
922
+ parsedTabsPosition.side === "top" || parsedTabsPosition.side === "left";
923
+
924
+ const stageAndDots = (
925
+ <>
926
+ {showNavigationDots && resolvedPageDotsPosition === "above" ? (
927
+ <nav
928
+ className={
929
+ pageDotsClassName
930
+ ? `ocs-page-dots ocs-page-dots--above ${pageDotsClassName}`
931
+ : "ocs-page-dots ocs-page-dots--above"
932
+ }
933
+ style={{ marginBottom: toCssDimension(pageDotsOffset) }}
934
+ aria-label="Card pages"
935
+ >
936
+ {cards.map((_, index) => {
937
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1);
938
+ const opacity = 0.25 + influence * 0.75;
939
+ const scale = 0.9 + influence * 0.22;
940
+
941
+ return (
942
+ <button
943
+ key={`ocs-page-dot-above-${index}`}
944
+ type="button"
945
+ className="ocs-page-dot"
946
+ aria-label={`Go to card ${index + 1}`}
947
+ aria-current={influence > 0.98 ? "page" : undefined}
948
+ onClick={() =>
949
+ focusCard(index, {
950
+ behavior: pageDotsBehavior,
951
+ transitionMode: "swoop",
952
+ })
953
+ }
954
+ style={{ opacity, transform: `scale(${scale})` }}
955
+ />
956
+ );
957
+ })}
958
+ </nav>
959
+ ) : null}
960
+ <div className="ocs-stage-frame">
961
+ <div
962
+ className="ocs-stage"
963
+ ref={stageRef}
964
+ style={{
965
+ minHeight: toCssDimension(cardHeight),
966
+ }}
967
+ onTouchStart={handleTouchStart}
968
+ onTouchMove={handleTouchMove}
969
+ onTouchEnd={handleTouchEnd}
970
+ onTouchCancel={handleTouchEnd}
971
+ >
972
+ <div className="ocs-track">
973
+ {cards.map((card, index) => {
974
+ const cardX = resolveCardX(
975
+ index,
976
+ activeIndex,
977
+ transitionProgress,
978
+ layout,
979
+ );
887
980
 
888
981
  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
- })
982
+ <div
983
+ key={card.key ?? `ocs-card-${index}`}
984
+ className={
985
+ cardContainerClassName
986
+ ? `${focusTransition ? "ocs-card ocs-card--focus-transition" : "ocs-card"} ${cardContainerClassName}`
987
+ : focusTransition
988
+ ? "ocs-card ocs-card--focus-transition"
989
+ : "ocs-card"
900
990
  }
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 (
991
+ style={{
992
+ width: `${layout.cardWidth}px`,
993
+ transform: `translate3d(${cardX}px, 0, 0)`,
994
+ transitionDuration: focusTransition
995
+ ? `${focusTransition.duration}ms`
996
+ : undefined,
997
+ ...cardContainerStyle,
998
+ pointerEvents: "none",
999
+ }}
1000
+ >
929
1001
  <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
1002
  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",
1003
+ pointerEvents: "auto",
1004
+ display: "flex",
1005
+ flexDirection: "column",
946
1006
  }}
947
1007
  >
948
- <div
949
- style={{
950
- pointerEvents: "auto",
951
- display: "flex",
952
- flexDirection: "column",
953
- }}
1008
+ <OverlappingCardsScrollCardIndexContext.Provider
1009
+ value={index}
954
1010
  >
955
- <OverlappingCardsScrollCardIndexContext.Provider
956
- value={index}
957
- >
958
- {card}
959
- </OverlappingCardsScrollCardIndexContext.Provider>
960
- </div>
1011
+ {card}
1012
+ </OverlappingCardsScrollCardIndexContext.Provider>
961
1013
  </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>
1014
+ </div>
1015
+ );
1016
+ })}
1017
+ </div>
1018
+ <div className="ocs-scroll-region" ref={scrollRef}>
1019
+ <div
1020
+ className="ocs-scroll-spacer"
1021
+ style={{
1022
+ width: `${layout.trackWidth}px`,
1023
+ }}
1024
+ />
973
1025
  </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
1026
  </div>
1009
- {showNavigationDots && resolvedPageDotsPosition === "below" ? (
1027
+ {showNavigationDots && resolvedPageDotsPosition === "overlay" ? (
1010
1028
  <nav
1011
1029
  className={
1012
1030
  pageDotsClassName
1013
- ? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
1014
- : "ocs-page-dots ocs-page-dots--below"
1031
+ ? `ocs-page-dots ocs-page-dots--overlay ${pageDotsClassName}`
1032
+ : "ocs-page-dots ocs-page-dots--overlay"
1015
1033
  }
1016
- style={{ marginTop: toCssDimension(pageDotsOffset) }}
1034
+ style={{ bottom: toCssDimension(pageDotsOffset) }}
1017
1035
  aria-label="Card pages"
1018
1036
  >
1019
1037
  {cards.map((_, index) => {
@@ -1023,7 +1041,7 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
1023
1041
 
1024
1042
  return (
1025
1043
  <button
1026
- key={`ocs-page-dot-below-${index}`}
1044
+ key={`ocs-page-dot-overlay-${index}`}
1027
1045
  type="button"
1028
1046
  className="ocs-page-dot"
1029
1047
  aria-label={`Go to card ${index + 1}`}
@@ -1040,7 +1058,62 @@ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
1040
1058
  })}
1041
1059
  </nav>
1042
1060
  ) : null}
1043
- {resolvedTabsPosition === "below" ? renderTabs("below") : null}
1061
+ </div>
1062
+ {showNavigationDots && resolvedPageDotsPosition === "below" ? (
1063
+ <nav
1064
+ className={
1065
+ pageDotsClassName
1066
+ ? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
1067
+ : "ocs-page-dots ocs-page-dots--below"
1068
+ }
1069
+ style={{ marginTop: toCssDimension(pageDotsOffset) }}
1070
+ aria-label="Card pages"
1071
+ >
1072
+ {cards.map((_, index) => {
1073
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1);
1074
+ const opacity = 0.25 + influence * 0.75;
1075
+ const scale = 0.9 + influence * 0.22;
1076
+
1077
+ return (
1078
+ <button
1079
+ key={`ocs-page-dot-below-${index}`}
1080
+ type="button"
1081
+ className="ocs-page-dot"
1082
+ aria-label={`Go to card ${index + 1}`}
1083
+ aria-current={influence > 0.98 ? "page" : undefined}
1084
+ onClick={() =>
1085
+ focusCard(index, {
1086
+ behavior: pageDotsBehavior,
1087
+ transitionMode: "swoop",
1088
+ })
1089
+ }
1090
+ style={{ opacity, transform: `scale(${scale})` }}
1091
+ />
1092
+ );
1093
+ })}
1094
+ </nav>
1095
+ ) : null}
1096
+ </>
1097
+ );
1098
+
1099
+ const stageContent = isVerticalTabs ? (
1100
+ <div className="ocs-main-column">{stageAndDots}</div>
1101
+ ) : (
1102
+ stageAndDots
1103
+ );
1104
+
1105
+ return (
1106
+ <OverlappingCardsScrollControllerContext.Provider
1107
+ value={controllerContextValue}
1108
+ >
1109
+ <section
1110
+ className={containerClassName}
1111
+ aria-label={ariaLabel}
1112
+ ref={containerRef}
1113
+ >
1114
+ {tabsBeforeStage ? renderTabs() : null}
1115
+ {stageContent}
1116
+ {!tabsBeforeStage ? renderTabs() : null}
1044
1117
  </section>
1045
1118
  </OverlappingCardsScrollControllerContext.Provider>
1046
1119
  );
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";