Skip to content

Commit 27ca348

Browse files
authored
Keep horizontal scrollbar visible in responsive (#1409)
Closes: RaspberryPiFoundation/digital-editor-issues#1196 The intent of this PR is being able to show a responsive overflow bar when we are in responsive. To achieve this it's been: - Added overlayscrollbars and overlayscrollbars-react container that handles this logic (MIT License 13kb gziped) - Added custom css to cover the exact usecase to reserve some space and make the scrollbar visibile on responsive https://github.com/user-attachments/assets/dcf9f392-61f8-494a-b4ff-aea1534c0296
1 parent ce388db commit 27ca348

7 files changed

Lines changed: 195 additions & 23 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"mime-types": "^2.1.35",
5151
"node-html-parser": "^6.1.5",
5252
"oidc-client": "^1.11.5",
53+
"overlayscrollbars": "^2.14.0",
54+
"overlayscrollbars-react": "^0.5.6",
5355
"plotly.js": "^3.3.1",
5456
"prismjs": "^1.29.0",
5557
"prompts": "2.4.0",

src/assets/stylesheets/ExternalStyles.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
@use "../../../node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css";
55
@use "../../../node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css";
66
@use "../../../node_modules/react-toastify/dist/ReactToastify.css";
7+
@use "../../../node_modules/overlayscrollbars/styles/overlayscrollbars.css";
78
@use "../../../node_modules/plotly.js/dist/plotly.css" as plotlyStyle;

src/assets/stylesheets/Project.scss

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@use "./rpf_design_system/spacing" as *;
22
@use "./rpf_design_system/colours" as *;
3+
@use "./ScratchContainer";
34

45
.proj {
56
display: flex;
@@ -130,7 +131,8 @@
130131
}
131132
}
132133

133-
.--light {
134+
.--light,
135+
#wc.--light {
134136
.proj-runner-container,
135137
.proj-editor-container,
136138
.sidebar {
@@ -146,7 +148,8 @@
146148
}
147149
}
148150

149-
.--dark {
151+
.--dark,
152+
#wc.--dark {
150153
.proj-runner-container,
151154
.proj-editor-container,
152155
.sidebar {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@use "./rpf_design_system/colours" as *;
2+
3+
.proj-container {
4+
.scratch-container {
5+
display: flex;
6+
flex-direction: column;
7+
flex: 1 1 auto;
8+
min-block-size: 0;
9+
min-inline-size: 0;
10+
}
11+
12+
.scratch-container__viewport {
13+
flex: 1 1 auto;
14+
min-block-size: 0;
15+
min-inline-size: 0;
16+
--scratch-scrollbar-size: 12px;
17+
--scratch-scrollbar-gap: 4px;
18+
}
19+
20+
.scratch-container__viewport > [data-overlayscrollbars-contents],
21+
.scratch-container__viewport [data-overlayscrollbars-viewport] {
22+
block-size: 100%;
23+
}
24+
25+
.scratch-container__viewport:has(> .os-scrollbar-horizontal.os-scrollbar-visible)
26+
> [data-overlayscrollbars-contents] {
27+
padding-block-end: calc(
28+
var(--scratch-scrollbar-size) + var(--scratch-scrollbar-gap)
29+
) !important;
30+
}
31+
32+
.scratch-container__iframe {
33+
display: block;
34+
inline-size: 100%;
35+
block-size: 100%;
36+
min-block-size: 0;
37+
}
38+
39+
.scratch-container__viewport:has(> .os-scrollbar-horizontal.os-scrollbar-visible)
40+
.scratch-container__iframe {
41+
block-size: calc(
42+
100% - var(--scratch-scrollbar-size) - var(--scratch-scrollbar-gap)
43+
);
44+
}
45+
46+
.os-theme-scratch {
47+
--os-size: var(--scratch-scrollbar-size);
48+
--os-padding-perpendicular: 0;
49+
--os-padding-axis: 0;
50+
--os-track-border-radius: 999px;
51+
--os-handle-border-radius: 999px;
52+
--os-track-bg: #{$rpf-grey-200};
53+
--os-track-bg-hover: #{$rpf-grey-200};
54+
--os-track-bg-active: #{$rpf-grey-200};
55+
--os-handle-bg: #{$rpf-grey-500};
56+
--os-handle-bg-hover: #{$rpf-grey-500};
57+
--os-handle-bg-active: #{$rpf-grey-500};
58+
}
59+
}

src/components/Editor/Project/ScratchContainer.jsx

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import React, { useEffect, useRef } from "react";
22
import { useDispatch, useSelector } from "react-redux";
3+
import { ClickScrollPlugin, OverlayScrollbars } from "overlayscrollbars";
4+
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
35
import { applyScratchProjectIdentifierUpdate } from "../../../redux/EditorSlice";
46
import {
57
subscribeToScratchProjectIdentifierUpdates,
68
postMessageToScratchIframe,
79
getScratchAllowedOrigin,
810
} from "../../../utils/scratchIframe";
911

12+
const SCRATCH_MIN_WIDTH = 1024;
13+
const SCRATCH_SCROLLBAR_OPTIONS = {
14+
overflow: {
15+
x: "scroll",
16+
y: "hidden",
17+
},
18+
scrollbars: {
19+
theme: "os-theme-scratch",
20+
visibility: "auto",
21+
clickScroll: "instant",
22+
},
23+
};
24+
25+
OverlayScrollbars.plugin(ClickScrollPlugin);
26+
1027
export default function ScratchContainer() {
1128
const dispatch = useDispatch();
1229
const projectIdentifier = useSelector(
@@ -87,15 +104,24 @@ export default function ScratchContainer() {
87104
}/scratch.html?${queryParams.toString()}`;
88105

89106
return (
90-
<iframe
91-
src={iframeSrcUrl}
92-
title={"Scratch"}
93-
style={{
94-
width: "100%",
95-
height: "100%",
96-
border: 0,
97-
display: "block",
98-
}}
99-
/>
107+
<div className="scratch-container" data-testid="scratch-container">
108+
<OverlayScrollbarsComponent
109+
className="scratch-container__viewport"
110+
data-testid="scratch-container-viewport"
111+
options={SCRATCH_SCROLLBAR_OPTIONS}
112+
>
113+
<iframe
114+
className="scratch-container__iframe"
115+
src={iframeSrcUrl}
116+
title={"Scratch"}
117+
style={{
118+
width: "100%",
119+
minWidth: `${SCRATCH_MIN_WIDTH}px`,
120+
border: 0,
121+
display: "block",
122+
}}
123+
/>
124+
</OverlayScrollbarsComponent>
125+
</div>
100126
);
101127
}

src/components/Editor/Project/ScratchContainer.test.js

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
22
import React, { act } from "react";
33
import { Provider } from "react-redux";
44
import { configureStore } from "@reduxjs/toolkit";
5-
import ScratchContainer from "./ScratchContainer";
65
import EditorReducer from "../../../redux/EditorSlice";
76
import * as scratchIframeUtils from "../../../utils/scratchIframe";
87

@@ -11,6 +10,34 @@ jest.mock("../../../utils/scratchIframe", () => ({
1110
postMessageToScratchIframe: jest.fn(),
1211
}));
1312

13+
const renderMockOverlayScrollbarsComponent = ({
14+
children,
15+
className,
16+
"data-testid": dataTestId,
17+
}) => (
18+
<div className={className} data-testid={dataTestId}>
19+
{children}
20+
</div>
21+
);
22+
23+
const mockOverlayScrollbarsComponent = jest.fn(
24+
renderMockOverlayScrollbarsComponent,
25+
);
26+
const mockPlugin = jest.fn();
27+
28+
jest.mock("overlayscrollbars-react", () => ({
29+
OverlayScrollbarsComponent: (props) => mockOverlayScrollbarsComponent(props),
30+
}));
31+
32+
jest.mock("overlayscrollbars", () => ({
33+
ClickScrollPlugin: { name: "ClickScrollPlugin" },
34+
OverlayScrollbars: {
35+
plugin: (...args) => mockPlugin(...args),
36+
},
37+
}));
38+
39+
const ScratchContainer = require("./ScratchContainer").default;
40+
1441
describe("ScratchContainer", () => {
1542
const defaultEditorState = {
1643
project: {
@@ -32,13 +59,20 @@ describe("ScratchContainer", () => {
3259
},
3360
});
3461

35-
const renderScratchContainer = (store) =>
62+
const renderScratchContainer = (store = buildStore()) => {
3663
render(
3764
<Provider store={store}>
3865
<ScratchContainer />
3966
</Provider>,
4067
);
4168

69+
return {
70+
iframe: screen.getByTitle("Scratch"),
71+
store,
72+
viewport: screen.getByTestId("scratch-container-viewport"),
73+
};
74+
};
75+
4276
const dispatchMessage = (data, origin = "https://example.com") => {
4377
window.dispatchEvent(
4478
new MessageEvent("message", {
@@ -77,6 +111,9 @@ describe("ScratchContainer", () => {
77111
originalAssetsUrl = process.env.ASSETS_URL;
78112
process.env.ASSETS_URL = "https://example.com";
79113
localStorage.clear();
114+
mockOverlayScrollbarsComponent.mockImplementation(
115+
renderMockOverlayScrollbarsComponent,
116+
);
80117
});
81118

82119
afterEach(() => {
@@ -85,23 +122,47 @@ describe("ScratchContainer", () => {
85122
});
86123

87124
test("renders iframe with src built from project_id and api_url", () => {
88-
const store = buildStore();
89-
renderScratchContainer(store);
125+
const { iframe, viewport } = renderScratchContainer();
90126

91-
const iframe = screen.getByTitle("Scratch");
127+
expect(screen.getByTestId("scratch-container")).toHaveClass(
128+
"scratch-container",
129+
);
130+
expect(viewport).toHaveClass("scratch-container__viewport");
92131
expect(iframe).toBeInTheDocument();
132+
expect(iframe).toHaveStyle({
133+
minWidth: "1024px",
134+
});
93135

94136
const url = new URL(iframe.getAttribute("src"));
95137
expect(url.pathname).toBe("/scratch.html");
96138
expect(url.searchParams.get("project_id")).toBe("project-123");
97139
expect(url.searchParams.get("api_url")).toBe("https://api.example.com/v1");
98140
});
99141

100-
test("updates the parent project identifier without reloading the iframe project_id", async () => {
101-
const store = buildStore();
102-
renderScratchContainer(store);
142+
test("configures OverlayScrollbars for an overflow-aware horizontal Scratch scrollbar", () => {
143+
renderScratchContainer();
103144

104-
await act(async () => {
145+
expect(mockOverlayScrollbarsComponent).toHaveBeenCalled();
146+
147+
const props = mockOverlayScrollbarsComponent.mock.calls[0][0];
148+
149+
expect(props.options).toEqual({
150+
overflow: {
151+
x: "scroll",
152+
y: "hidden",
153+
},
154+
scrollbars: {
155+
theme: "os-theme-scratch",
156+
visibility: "auto",
157+
clickScroll: "instant",
158+
},
159+
});
160+
});
161+
162+
test("updates the parent project identifier without reloading the iframe project_id", () => {
163+
const { store } = renderScratchContainer();
164+
165+
act(() => {
105166
dispatchMessage({
106167
type: "scratch-gui-project-id-updated",
107168
projectId: "project-456",
@@ -121,8 +182,8 @@ describe("ScratchContainer", () => {
121182
const store = buildStore({
122183
authReducer: (state = { user: { access_token: "token-123" } }) => state,
123184
});
124-
renderScratchContainer(store);
125185

186+
renderScratchContainer(store);
126187
dispatchScratchGuiReady({ nonce: "nonce-abc" });
127188

128189
expectScratchSetTokenCall({
@@ -160,8 +221,8 @@ describe("ScratchContainer", () => {
160221
const store = buildStore({
161222
authReducer: (state = { user: { access_token: "token-123" } }) => state,
162223
});
163-
renderScratchContainer(store);
164224

225+
renderScratchContainer(store);
165226
dispatchScratchGuiReady({ nonce: "nonce-1" });
166227
dispatchScratchGuiReady({ nonce: "nonce-1" });
167228
dispatchScratchGuiReady({ nonce: "nonce-2" });
@@ -192,6 +253,7 @@ describe("ScratchContainer", () => {
192253
user: { access_token: action.payload },
193254
};
194255
}
256+
195257
return state;
196258
},
197259
});

yarn.lock

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4276,6 +4276,8 @@ __metadata:
42764276
node-html-parser: "npm:^6.1.5"
42774277
oidc-client: "npm:^1.11.5"
42784278
optimize-css-assets-webpack-plugin: "npm:5.0.4"
4279+
overlayscrollbars: "npm:^2.14.0"
4280+
overlayscrollbars-react: "npm:^0.5.6"
42794281
path-browserify: "npm:^1.0.1"
42804282
plotly.js: "npm:^3.3.1"
42814283
pnp-webpack-plugin: "npm:1.6.4"
@@ -18065,6 +18067,23 @@ __metadata:
1806518067
languageName: node
1806618068
linkType: hard
1806718069

18070+
"overlayscrollbars-react@npm:^0.5.6":
18071+
version: 0.5.6
18072+
resolution: "overlayscrollbars-react@npm:0.5.6"
18073+
peerDependencies:
18074+
overlayscrollbars: ^2.0.0
18075+
react: ">=16.8.0"
18076+
checksum: 10/473f5af860feab4b5418f9adc8e356fb201e9de61286443ff64002b9c997bc19bf17cf60e314c502c14ca41fa213c12f18111e6fe913be86ad68a15c32e66789
18077+
languageName: node
18078+
linkType: hard
18079+
18080+
"overlayscrollbars@npm:^2.14.0":
18081+
version: 2.14.0
18082+
resolution: "overlayscrollbars@npm:2.14.0"
18083+
checksum: 10/6e0b91554d98bfefc876a4dde9cf09db537d86f88afa069b520910fa5727ae9ac60f595b5e85cc1d13e6da4e9b41b377e3a19185a726aea53a8fe3c1c19e1ea0
18084+
languageName: node
18085+
linkType: hard
18086+
1806818087
"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0":
1806918088
version: 2.3.0
1807018089
resolution: "p-limit@npm:2.3.0"

0 commit comments

Comments
 (0)