Skip to content

Commit 0d10a08

Browse files
krisantrobusTheSisbnkrantzserifluous
authored
feat(responsive-tabs): allow scroll on components with tabs (#4067)
* feat(tabs): make non-fitted tabs responsive * chore: fixes * fix: add missing 4px under scrollbar * chore: fixes to codeblock overflow tabs * fix(codeblock): improve shadow behavior * fix(tabs): fitted tab styling issue * chore: fix build * chore: fixes * feat(tabs): a11y + code cleanup * feat(in-page-navigation): added styled scroll * feat(in-page-navigation): remove unnecessary padding * feat(code-block): add shadows to scroll * feat(code-block): a11y * chore(pr): changeset for codemod * chore(testing): testing for responsive tabs * chore(code-block): code cleanup * chore(pr): cleanup * feat(in-page-navigation): add scroll to active on mount * chore(pr): fix lint issues * chore(pr): fix failing tests & lint * chore(pr): lint fix * chore(pr): address comments * feat(tokens): added box shadow for scroll inverse * chore(pr): update to use boudning client * chore(pr): clenaup * chore(pr): clenaup * chore(pr): typedocs * chore(pr): fix tests * chore(pr): story responsive fixed widths * Update packages/paste-design-tokens/tokens/global/box-shadow.yml Co-authored-by: Nora Krantz <[email protected]> * chore(pr): fix flex issue * feat(tabs): inital scroll arrow impl * feat(tokens): udpate for shadows * feat(tabs): unrefined overflow buttons * feat(tabs): unrefined overflow buttons * feat(tabs): customization to elements * chore(tabs): fix tests * chore(tabs): add customization examples * chore(tabs): comments and eventsncleanup * chore(tabs): code optimization * chore(tabs): code inverse styles * feat(tabs): shadow on scroll * chore(tabs): linting issues * feat(tabs): update button sizes * feat(code-block): remove line under overflow buttons * chore(tabs): fix test * feat(tabs): handle sinfle tab in view issue * feat(tabs): overflow button sizings * feat(tabs): overflow button sizings * chore(tabs): ful width and border * chore(in-page-navigation): columnGap * chore(tabs): add columnGap back * chore(code-block): add columnGap back * chore(code-block): overflow button width * feat(code-block): fix underline issue * fix: inverse colors Co-authored-by: Sarah <[email protected]> * chore(tabs): refactor to pull scroll logic into hooks * chore(ci): lint fixes --------- Co-authored-by: TheSisb <[email protected]> Co-authored-by: Nora Krantz <[email protected]> Co-authored-by: Sarah <[email protected]>
1 parent 779cd45 commit 0d10a08

File tree

32 files changed

+1365
-206
lines changed

32 files changed

+1365
-206
lines changed

.changeset/fresh-points-breathe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/design-tokens": minor
3+
"@twilio-paste/core": minor
4+
---
5+
6+
[Design Token] added new box shadows to support scrollable styling on inverse colored components

.changeset/grumpy-dryers-remain.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/code-block": patch
3+
"@twilio-paste/core": patch
4+
---
5+
6+
[CodeBlock] make tabs responsive

.changeset/loud-items-rhyme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/in-page-navigation": patch
3+
"@twilio-paste/core": patch
4+
---
5+
6+
[In Page Navigation] make items scrollable
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@twilio-paste/tabs": minor
3+
"@twilio-paste/core": minor
4+
"@twilio-paste/codemods": minor
5+
---
6+
7+
[Tabs] make the non-fitted variant Tabs responsive. Export the context provider `TabsContext`.

packages/paste-codemods/tools/.cache/mappings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@
306306
"TabPanel": "@twilio-paste/core/tabs",
307307
"TabPanels": "@twilio-paste/core/tabs",
308308
"Tabs": "@twilio-paste/core/tabs",
309+
"TabsContext": "@twilio-paste/core/tabs",
309310
"useTabState": "@twilio-paste/core/tabs",
310311
"TextArea": "@twilio-paste/core/textarea",
311312
"TimePicker": "@twilio-paste/core/time-picker",

packages/paste-core/components/code-block/__tests__/customization.spec.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const CustomizationWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
2727
CODE_BLOCK_TAB: { borderRadius: "borderRadius0" },
2828
CODE_BLOCK_WRAPPER: { width: "size50" },
2929
CODE_BLOCK: { width: "size50" },
30+
CODE_BLOCK_TAB_LIST_CHILD: { backgroundColor: "colorBackgroundError" },
31+
CODE_BLOCK_TAB_LIST_CHILD_SCROLL_WRAPPER: { backgroundColor: "colorBackgroundAvailable" },
3032
}}
3133
>
3234
{children}
@@ -47,6 +49,8 @@ const CustomizationMyWrapper: React.FC<React.PropsWithChildren> = ({ children })
4749
MY_CODE_BLOCK_TAB: { borderRadius: "borderRadius0" },
4850
MY_CODE_BLOCK_WRAPPER: { width: "size50" },
4951
MY_CODE_BLOCK: { width: "size50" },
52+
MY_CODE_BLOCK_TAB_LIST_CHILD: { backgroundColor: "colorBackgroundError" },
53+
MY_CODE_BLOCK_TAB_LIST_CHILD_SCROLL_WRAPPER: { backgroundColor: "colorBackgroundAvailable" },
5054
}}
5155
>
5256
{children}
@@ -85,6 +89,10 @@ describe("Customization", () => {
8589
expect(content?.getAttribute("data-paste-element")).toBe("CODE_BLOCK_CONTENT");
8690
expect(tabList.getAttribute("data-paste-element")).toBe("CODE_BLOCK_TAB_LIST");
8791
expect(tab.getAttribute("data-paste-element")).toBe("CODE_BLOCK_TAB");
92+
expect(tab.parentElement?.getAttribute("data-paste-element")).toBe("CODE_BLOCK_TAB_LIST_CHILD");
93+
expect(tab.parentElement?.parentElement?.getAttribute("data-paste-element")).toBe(
94+
"CODE_BLOCK_TAB_LIST_CHILD_SCROLL_WRAPPER",
95+
);
8896
expect(tabPanel?.getAttribute("data-paste-element")).toBe("CODE_BLOCK_TAB_PANEL");
8997
expect(codeBlock.getAttribute("data-paste-element")).toBe("CODE_BLOCK");
9098
expect(heading.getAttribute("data-paste-element")).toBe("CODE_BLOCK_HEADER");
@@ -128,6 +136,10 @@ describe("Customization", () => {
128136
expect(content?.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_CONTENT");
129137
expect(tabList.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_TAB_LIST");
130138
expect(tab.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_TAB");
139+
expect(tab.parentElement?.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_TAB_LIST_CHILD");
140+
expect(tab.parentElement?.parentElement?.getAttribute("data-paste-element")).toBe(
141+
"MY_CODE_BLOCK_TAB_LIST_CHILD_SCROLL_WRAPPER",
142+
);
131143
expect(tabPanel?.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_TAB_PANEL");
132144
expect(codeBlock.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK");
133145
expect(heading.getAttribute("data-paste-element")).toBe("MY_CODE_BLOCK_HEADER");
@@ -167,6 +179,8 @@ describe("Customization", () => {
167179
expect(heading).toHaveStyleRule("border-top-right-radius", "8px");
168180
expect(tabList).toHaveStyleRule("column-gap", "0");
169181
expect(tab).toHaveStyleRule("border-radius", "0");
182+
expect(tab.parentElement).toHaveStyleRule("background-color", "rgb(214, 31, 31)");
183+
expect(tab.parentElement?.parentElement).toHaveStyleRule("background-color", "rgb(20, 176, 83)");
170184
expect(tabPanel).toHaveStyleRule("border-bottom-right-radius", "8px");
171185
expect(copyButton).toHaveStyleRule("background-color", "rgb(254, 236, 236)");
172186
expect(externalLink).toHaveStyleRule("background-color", "rgb(254, 236, 236)");
@@ -210,6 +224,8 @@ describe("Customization", () => {
210224
expect(heading).toHaveStyleRule("border-top-right-radius", "8px");
211225
expect(tabList).toHaveStyleRule("column-gap", "0");
212226
expect(tab).toHaveStyleRule("border-radius", "0");
227+
expect(tab.parentElement).toHaveStyleRule("background-color", "rgb(214, 31, 31)");
228+
expect(tab.parentElement?.parentElement).toHaveStyleRule("background-color", "rgb(20, 176, 83)");
213229
expect(tabPanel).toHaveStyleRule("border-bottom-right-radius", "8px");
214230
expect(copyButton).toHaveStyleRule("background-color", "rgb(254, 236, 236)");
215231
expect(externalLink).toHaveStyleRule("background-color", "rgb(254, 236, 236)");

packages/paste-core/components/code-block/src/CodeBlockTabList.tsx

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
1-
import { Box } from "@twilio-paste/box";
1+
import { Box, safelySpreadBoxProps } from "@twilio-paste/box";
22
import type { BoxProps } from "@twilio-paste/box";
3-
import { TabList } from "@twilio-paste/tabs";
4-
import type { TabListProps } from "@twilio-paste/tabs";
3+
import { css, styled } from "@twilio-paste/styling-library";
4+
import { type TabListProps, TabsContext } from "@twilio-paste/tabs";
5+
import { TabPrimitiveList } from "@twilio-paste/tabs-primitive";
56
import * as React from "react";
67

8+
import { OverflowButton } from "./OverflowButton";
9+
import { handleScrollDirection, useElementsOutOfBounds, useShowShadow } from "./utlis";
10+
11+
/**
12+
* This wrapper applies styles that customize the scrollbar and its track.
13+
*/
14+
const StyledTabList = styled.div(() => {
15+
return css({
16+
overflowX: "auto",
17+
overflowY: "hidden",
18+
overflowScrolling: "touch",
19+
/* Firefox scrollbar */
20+
"@supports (-moz-appearance:none)": {
21+
paddingBottom: "0px",
22+
scrollbarWidth: "none",
23+
},
24+
/* Chrome + Safari scrollbar */
25+
"::-webkit-scrollbar": {
26+
height: 0,
27+
},
28+
"::-webkit-scrollbar-track": {
29+
background: "transparent",
30+
},
31+
});
32+
});
33+
734
export interface CodeBlockTabListProps extends Omit<TabListProps, "aria-label"> {
835
/**
936
* Overrides the default element name to apply unique styles with the Customization Provider
@@ -17,12 +44,86 @@ export interface CodeBlockTabListProps extends Omit<TabListProps, "aria-label">
1744

1845
export const CodeBlockTabList = React.forwardRef<HTMLDivElement, CodeBlockTabListProps>(
1946
({ children, element = "CODE_BLOCK_TAB_LIST", ...props }, ref) => {
47+
const tabContext = React.useContext(TabsContext);
48+
// ref to the scrollable element
49+
const scrollableRef = React.useRef<HTMLDivElement>(null);
50+
const listRef = React.useRef<HTMLDivElement>(null);
51+
52+
const { determineElementsOutOfBounds, elementOutOBoundsLeft, elementOutOBoundsRight } = useElementsOutOfBounds();
53+
const { handleShadow, showShadow } = useShowShadow();
54+
55+
const handleScrollEvent = (): void => {
56+
handleShadow();
57+
determineElementsOutOfBounds(scrollableRef.current, listRef.current);
58+
};
59+
60+
React.useEffect(() => {
61+
if (scrollableRef.current && listRef.current) {
62+
scrollableRef.current.addEventListener("scroll", handleScrollEvent);
63+
window.addEventListener("resize", handleScrollEvent);
64+
determineElementsOutOfBounds(scrollableRef.current, listRef.current);
65+
}
66+
}, [scrollableRef.current, listRef.current]);
67+
68+
// Cleanup event listeners on destroy
69+
React.useEffect(() => {
70+
return () => {
71+
if (scrollableRef.current) {
72+
scrollableRef.current.removeEventListener("scroll", handleScrollEvent);
73+
window.removeEventListener("resize", handleScrollEvent);
74+
}
75+
};
76+
}, []);
77+
2078
return (
21-
<Box paddingX="space70">
22-
<TabList {...props} aria-label="label" ref={ref} element={element}>
23-
{children}
24-
</TabList>
25-
</Box>
79+
<TabPrimitiveList {...(tabContext as any)} as={Box} {...props} element={element} ref={ref}>
80+
<Box element={`${element}_CHILD_WRAPPER`} display="flex">
81+
<OverflowButton
82+
position="left"
83+
onClick={() =>
84+
handleScrollDirection("left", elementOutOBoundsLeft, elementOutOBoundsRight, listRef.current)
85+
}
86+
visible={Boolean(elementOutOBoundsLeft)}
87+
element={element}
88+
showShadow={showShadow}
89+
/>
90+
<Box
91+
{...safelySpreadBoxProps(props)}
92+
as={StyledTabList as any}
93+
ref={scrollableRef}
94+
display="flex"
95+
flexWrap="nowrap"
96+
element={`${element}_CHILD_SCROLL_WRAPPER`}
97+
overflowX="auto"
98+
overflowY="hidden"
99+
flexGrow={1}
100+
width="calc(100% - 60px)"
101+
>
102+
<Box
103+
whiteSpace="nowrap"
104+
element={`${element}_CHILD`}
105+
display="flex"
106+
borderBottomStyle="solid"
107+
borderBottomWidth="borderWidth10"
108+
borderBottomColor="colorBorderInverseWeaker"
109+
ref={listRef}
110+
flexGrow={1}
111+
columnGap="space20"
112+
>
113+
{children}
114+
</Box>
115+
</Box>
116+
<OverflowButton
117+
position="right"
118+
onClick={() =>
119+
handleScrollDirection("right", elementOutOBoundsLeft, elementOutOBoundsRight, listRef.current)
120+
}
121+
visible={Boolean(elementOutOBoundsRight)}
122+
element={element}
123+
showShadow={showShadow}
124+
/>
125+
</Box>
126+
</TabPrimitiveList>
26127
);
27128
},
28129
);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Box, BoxProps, BoxStyleProps } from "@twilio-paste/box";
2+
import { ChevronLeftIcon } from "@twilio-paste/icons/esm/ChevronLeftIcon";
3+
import { ChevronRightIcon } from "@twilio-paste/icons/esm/ChevronRightIcon";
4+
import { useTheme } from "@twilio-paste/theme";
5+
import React from "react";
6+
7+
interface OverflowButtonProps {
8+
onClick: () => void;
9+
position: "left" | "right";
10+
visible?: boolean;
11+
element?: BoxProps["element"];
12+
showShadow?: boolean;
13+
}
14+
15+
const Styles: BoxStyleProps = {
16+
color: "colorTextIconInverse",
17+
_hover: {
18+
color: "colorTextInverse",
19+
cursor: "pointer",
20+
},
21+
};
22+
23+
export const OverflowButton: React.FC<OverflowButtonProps> = ({
24+
onClick,
25+
position,
26+
visible,
27+
element = "CODE_BLOCK_TAB_LIST",
28+
showShadow,
29+
}) => {
30+
const theme = useTheme();
31+
const Chevron = position === "left" ? ChevronLeftIcon : ChevronRightIcon;
32+
if (!visible && position === "right") return null;
33+
34+
return (
35+
<Box
36+
onClick={onClick}
37+
aria-hidden={true}
38+
display="flex"
39+
alignItems="center"
40+
justifyContent="center"
41+
width="sizeIcon40"
42+
padding="space20"
43+
position="relative"
44+
boxShadow={visible && showShadow ? theme.shadows.shadowScrollInverse : undefined}
45+
element={`${element}_OVERFLOW_BUTTON_${position.toUpperCase()}`}
46+
cursor={visible ? "pointer" : "none"}
47+
{...Styles}
48+
>
49+
{/* For left button to align with spacing of header we hide icon */}
50+
{visible && <Chevron decorative={true} />}
51+
</Box>
52+
);
53+
};
54+
55+
OverflowButton.displayName = "OverflowButton";
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from "react";
2+
3+
export const useElementsOutOfBounds = (): {
4+
elementOutOBoundsLeft: HTMLDivElement | null;
5+
elementOutOBoundsRight: HTMLDivElement | null;
6+
determineElementsOutOfBounds: (scrollContainer: HTMLDivElement | null, listContainer: HTMLElement | null) => void;
7+
} => {
8+
// Keep track of first elements that are paritally or completely out of view in either direction
9+
const [elementOutOBoundsLeft, setElementOutOfBoundsLeft] = React.useState<HTMLDivElement | null>(null);
10+
const [elementOutOBoundsRight, setElementOutOfBoundsRight] = React.useState<HTMLDivElement | null>(null);
11+
12+
// called on load and resize and on scroll to set the elements that are out of view
13+
const determineElementsOutOfBounds = (
14+
scrollContainer: HTMLDivElement | null,
15+
listContainer: HTMLElement | null,
16+
): void => {
17+
if (scrollContainer && listContainer) {
18+
const currentScrollContainerRightPosition = (scrollContainer as HTMLDivElement)?.getBoundingClientRect().right;
19+
const currentScrollContainerXOffset = (scrollContainer as HTMLDivElement)?.getBoundingClientRect().x;
20+
21+
let leftOutOfBounds: HTMLDivElement | null = null;
22+
let rightOutOfBounds: HTMLDivElement | null = null;
23+
24+
(listContainer.childNodes as NodeListOf<HTMLDivElement>).forEach((tab) => {
25+
const { x, right } = tab.getBoundingClientRect();
26+
// Check if the tab is spanning the view if text is really long on smaller devices, wont skip to next element
27+
const isSpanningView = x < currentScrollContainerXOffset && right > currentScrollContainerRightPosition;
28+
if (!isSpanningView) {
29+
/**
30+
* Compares the left side of the tab with the left side of the scrollable container position
31+
* as the x value will not be 0 due to being offset in the screen.
32+
*/
33+
if (x < currentScrollContainerXOffset) {
34+
leftOutOfBounds = tab;
35+
}
36+
/**
37+
* Compares the right side to the end of container with some buffer. Also ensure there are
38+
* no value set as it loops through the array we don't want it to override the first value out of bounds.
39+
*/
40+
if (right > currentScrollContainerRightPosition + 10 && !rightOutOfBounds) {
41+
rightOutOfBounds = tab;
42+
}
43+
}
44+
45+
setElementOutOfBoundsLeft(leftOutOfBounds);
46+
setElementOutOfBoundsRight(rightOutOfBounds);
47+
});
48+
}
49+
};
50+
51+
return { elementOutOBoundsLeft, elementOutOBoundsRight, determineElementsOutOfBounds };
52+
};
53+
54+
export const useShowShadow = (): { showShadow: boolean; handleShadow: () => void } => {
55+
const [showShadow, setShowShadow] = React.useState(false);
56+
let showShadowTimer: number;
57+
58+
const handleShadow = (): void => {
59+
if (showShadowTimer) {
60+
window.clearTimeout(showShadowTimer);
61+
}
62+
setShowShadow(true);
63+
showShadowTimer = window.setTimeout(() => {
64+
setShowShadow(false);
65+
}, 500);
66+
};
67+
68+
return { showShadow, handleShadow };
69+
};
70+
71+
/**
72+
* Scrolls to the element that is out of bounds (from React State), centering it in the scrollable container
73+
* Logic to handle scrolling also replicated in CodeBlock and InPageNavigation. If changing here, consider reviewing those components too.
74+
*/
75+
export const handleScrollDirection = (
76+
direction: "left" | "right",
77+
elementOutOBoundsLeft: HTMLDivElement | null,
78+
elementOutOBoundsRight: HTMLDivElement | null,
79+
listContainer: HTMLElement | null,
80+
): void => {
81+
if (listContainer) {
82+
const elementToScrollTo = direction === "left" ? elementOutOBoundsLeft : elementOutOBoundsRight;
83+
if (elementToScrollTo) {
84+
elementToScrollTo.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
85+
}
86+
}
87+
};

0 commit comments

Comments
 (0)