Skip to content

Commit 030ca1e

Browse files
authored
feat: mobile support (#91)
1 parent 559b8e4 commit 030ca1e

File tree

14 files changed

+296
-161
lines changed

14 files changed

+296
-161
lines changed

docs/demo/uno.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ const customIconCollection = iconPaths.reduce(
2222
export default defineConfig({
2323
...unoCSSConfig,
2424
content: {
25-
inline: globSync(
25+
inline: globSync([
2626
`${convertPathToPattern(join(require.resolve('@tutorialkit/components-react'), '..'))}/**/*.js`,
27-
).map((filePath) => {
27+
`${convertPathToPattern(join(require.resolve('@tutorialkit/astro'), '..'))}/default/**/*.astro`,
28+
]).map((filePath) => {
2829
return () => fs.readFile(filePath, { encoding: 'utf8' });
2930
}),
3031
},
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
import { NavWrapper as Nav } from './NavWrapper';
3+
import { WorkspacePanelWrapper as WorkspacePanel } from './WorkspacePanelWrapper';
4+
import TutorialContent from './TutorialContent.astro';
5+
import ResizablePanel from './ResizablePanel.astro';
6+
import MobileContentToggle from './MobileContentToggle.astro';
7+
import { RESIZABLE_PANELS } from '../utils/constants';
8+
import type { Lesson, NavList } from '@tutorialkit/types';
9+
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
10+
import { hasWorkspace } from '../utils/workspace';
11+
12+
interface Props {
13+
lesson: Lesson<AstroComponentFactory>;
14+
navList: NavList;
15+
}
16+
17+
const { lesson, navList } = Astro.props;
18+
19+
const showWorkspacePanel = hasWorkspace(lesson);
20+
---
21+
22+
<ResizablePanel
23+
class="h-full overflow-hidden"
24+
id={RESIZABLE_PANELS.Main}
25+
type="horizontal"
26+
min="30%"
27+
pos="40%"
28+
max="60%"
29+
>
30+
<div class="h-full flex flex-col bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor" slot="a">
31+
<Nav client:load lesson={lesson} navList={navList} />
32+
<TutorialContent lesson={lesson} />
33+
</div>
34+
<div class="h-full sm:border-l border-tk-elements-app-borderColor" slot={showWorkspacePanel ? 'b' : 'hide'}>
35+
<WorkspacePanel lesson={lesson} client:load transition:persist />
36+
</div>
37+
</ResizablePanel>
38+
<MobileContentToggle />
39+
<script>
40+
import { viewStore } from '../stores/view-store';
41+
import { RESIZABLE_PANELS } from '../utils/constants';
42+
import type { IResizablePanel } from './ResizablePanel.astro';
43+
44+
const DEFAULT_PANEL_CLASS_LIST = ['sm:transition-none', 'sm:translate-x-0', 'absolute', 'inset-0', 'sm:static'];
45+
46+
let subscriber: (() => void) | undefined;
47+
48+
document.addEventListener('astro:page-load', () => {
49+
subscriber?.();
50+
51+
const resizablePanel = document.querySelector(`[data-id=${RESIZABLE_PANELS.Main}]`) as unknown as IResizablePanel;
52+
const contentPanel = resizablePanel.mainPanel();
53+
const editorPanel = resizablePanel.sidePanel();
54+
const divider = resizablePanel.divider();
55+
56+
if (!editorPanel) {
57+
subscriber = undefined;
58+
return;
59+
}
60+
61+
contentPanel.classList.add(...DEFAULT_PANEL_CLASS_LIST);
62+
editorPanel.classList.add(...DEFAULT_PANEL_CLASS_LIST);
63+
divider?.classList.add('hidden', 'sm:block');
64+
65+
subscriber = viewStore.subscribe((value) => {
66+
if (value === 'content') {
67+
contentPanel.classList.remove('-translate-x-full');
68+
editorPanel.classList.add('translate-x-full');
69+
} else {
70+
contentPanel.classList.add('-translate-x-full');
71+
editorPanel.classList.remove('translate-x-full');
72+
}
73+
});
74+
75+
requestAnimationFrame(() => {
76+
contentPanel.classList.add('transition-transform');
77+
editorPanel.classList.add('transition-transform');
78+
});
79+
});
80+
</script>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<div class="h-12 sm:hidden"></div>
2+
<view-toggle
3+
class="fixed sm:hidden h-12 bottom-0 w-full bg-tk-elements-app-backgroundColor border-t border-tk-elements-app-borderColor flex justify-center items-center z-60 text-tk-elements-app-textColor"
4+
>
5+
<button>Tutorial</button>
6+
<button class="rounded-full w-8 h-4 p-0.5 bg-gray-4 dark:bg-gray-6 mx-2 relative">
7+
<span class="inline-block transition-all absolute top-0.5 left-0.5 rounded-full bg-white dark:bg-gray-2 w-3 h-3"
8+
></span>
9+
</button>
10+
<button>Editor</button>
11+
</view-toggle>
12+
<script>
13+
import { viewStore, type View } from '../stores/view-store';
14+
15+
class ViewToggle extends HTMLElement {
16+
constructor() {
17+
super();
18+
19+
const [tutorialBtn, toggleButton, editorButton] = this.querySelectorAll(':scope > button' as 'button');
20+
21+
const toggle = toggleButton.querySelector('span')!;
22+
23+
tutorialBtn.onclick = () => setView('content');
24+
editorButton.onclick = () => setView('editor');
25+
toggleButton.onclick = () => setView(viewStore.get() === 'content' ? 'editor' : 'content');
26+
27+
setView(viewStore.get());
28+
29+
function setView(view: View) {
30+
viewStore.set(view);
31+
32+
if (view === 'editor') {
33+
toggle.classList.remove('left-0.5');
34+
toggle.classList.add('left-[calc(100%-0.75rem-0.125rem)]');
35+
} else {
36+
toggle.classList.add('left-0.5');
37+
toggle.classList.remove('left-[calc(100%-0.75rem-0.125rem)]');
38+
}
39+
}
40+
}
41+
}
42+
43+
customElements.define('view-toggle', ViewToggle);
44+
</script>
Lines changed: 61 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
---
2+
import { classNames } from '@tutorialkit/components-react';
3+
24
export type Type = 'horizontal' | 'vertical';
35
export type Priority = 'min' | 'max';
46
@@ -8,11 +10,25 @@ interface Props {
810
pos?: string;
911
min?: string;
1012
max?: string;
11-
priority?: 'min' | 'max';
12-
classList?: string;
13+
class?: string;
14+
sidePanelClass?: string;
15+
}
16+
17+
export interface IResizablePanel {
18+
mainPanel(): HTMLElement;
19+
sidePanel(): HTMLElement | undefined;
20+
divider(): HTMLElement | undefined;
1321
}
1422
15-
let { id, type = 'horizontal', min = '0%', pos = '50%', max = '100%', priority = 'min', classList } = Astro.props;
23+
let {
24+
id,
25+
type = 'horizontal',
26+
min = '0%',
27+
pos = '50%',
28+
max = '100%',
29+
class: className = '',
30+
sidePanelClass = '',
31+
} = Astro.props;
1632
1733
// check if there is a `slot` defined with name `b`
1834
const hasSidePanel = Astro.slots.has('b');
@@ -23,18 +39,19 @@ if (!hasSidePanel) {
2339
min = '100%';
2440
max = '100%';
2541
}
42+
43+
const panelClass = classNames('overflow-hidden', { 'h-full': type === 'horizontal' });
2644
---
2745

28-
<resizable-panel
29-
class={classList ?? ''}
30-
data-id={id}
31-
data-type={type}
32-
data-pos={pos}
33-
data-min={min}
34-
data-priority={priority}
35-
data-max={max}
36-
>
37-
<div class=`container max-w-full ${type}` style=`--pos: ${pos}`>
46+
<resizable-panel class={className} data-id={id} data-type={type} data-pos={pos} data-min={min} data-max={max}>
47+
<div
48+
data-id="container"
49+
class={classNames('sm:grid relative w-full h-full max-w-full', {
50+
'sm:grid-cols-[var(--pos)_1fr]': type === 'horizontal',
51+
'sm:grid-rows-[var(--pos)_1fr]': type !== 'horizontal',
52+
})}
53+
style=`--pos: ${pos}`
54+
>
3855
<!-- It's important to keep the inline script here because it restores the position and blocks rendering to avoid flickering -->
3956
<script is:inline define:vars={{ id, hasSidePanel }}>
4057
if (!hasSidePanel) {
@@ -44,36 +61,41 @@ if (!hasSidePanel) {
4461

4562
const sessionStorageKey = `tk_resizable_panel_${id}`;
4663

47-
const $container = document.querySelector(`resizable-panel[data-id="${id}"] > .container`);
64+
const $container = document.querySelector(`resizable-panel[data-id="${id}"] > div`);
4865
const pos = sessionStorage.getItem(sessionStorageKey);
4966

5067
if (pos) {
5168
$container.style.setProperty('--pos', pos);
5269
}
5370
</script>
54-
<div class="panel">
71+
<div class={panelClass} data-id="main-panel">
5572
<slot name="a" />
5673
</div>
5774
{
5875
hasSidePanel && (
5976
<>
60-
<div class="panel">
77+
<div class={`${panelClass} ${sidePanelClass}`} data-id="side-panel">
6178
<slot name="b" />
6279
</div>
63-
<div class="divider" />
80+
<div
81+
data-id="divider"
82+
class={classNames('absolute z-90 transition-colors hover:bg-gray-500/13', {
83+
'w-0 h-full left-[var(--pos)] cursor-ew-resize p-0 px-1.5 -translate-x-1/2': type === 'horizontal',
84+
'h-0 w-full top-[var(--pos)] cursor-ns-resize p-0 py-2 -translate-y-1/2': type !== 'horizontal',
85+
})}
86+
/>
6487
</>
6588
)
6689
}
6790
</div>
6891
</resizable-panel>
6992

7093
<script>
71-
import type { Priority, Type } from './ResizablePanel.astro';
94+
import type { Type, IResizablePanel } from './ResizablePanel.astro';
7295

73-
class ResizablePanel extends HTMLElement {
96+
class ResizablePanel extends HTMLElement implements IResizablePanel {
7497
readonly #id = this.dataset.id as string;
7598
readonly #type = this.dataset.type as Type;
76-
readonly #priority = this.dataset.min as Priority;
7799
readonly #min = this.dataset.min as string;
78100
readonly #max = this.dataset.max as string;
79101

@@ -83,13 +105,17 @@ if (!hasSidePanel) {
83105
#dragging = false;
84106

85107
#container: HTMLElement;
108+
#mainPanel: HTMLElement;
109+
#sidePanel: HTMLElement | undefined;
86110
#divider: HTMLElement | undefined;
87111

88112
constructor() {
89113
super();
90114

91-
this.#container = this.querySelector(':scope > .container') as HTMLElement;
92-
this.#divider = this.#container.querySelector(':scope > .divider') as HTMLElement | undefined;
115+
this.#container = this.querySelector(':scope > [data-id="container"]') as HTMLElement;
116+
this.#mainPanel = this.#container.querySelector(':scope > [data-id="main-panel"]') as HTMLElement;
117+
this.#sidePanel = this.#container.querySelector(':scope > [data-id="side-panel"]') as HTMLElement | undefined;
118+
this.#divider = this.#container.querySelector(':scope > [data-id="divider"]') as HTMLElement | undefined;
93119

94120
this.#width = this.#container.clientWidth;
95121
this.#height = this.#container.clientHeight;
@@ -111,6 +137,18 @@ if (!hasSidePanel) {
111137
}
112138
}
113139

140+
mainPanel(): HTMLElement {
141+
return this.#mainPanel;
142+
}
143+
144+
sidePanel(): HTMLElement | undefined {
145+
return this.#sidePanel;
146+
}
147+
148+
divider(): HTMLElement | undefined {
149+
return this.#divider;
150+
}
151+
114152
connectedCallback() {
115153
this.#divider?.addEventListener('mousedown', this.#onMouseDown.bind(this));
116154
this.#divider?.addEventListener('touchstart', this.#onMouseDown.bind(this), { passive: true });
@@ -179,7 +217,6 @@ if (!hasSidePanel) {
179217
}
180218

181219
#setPosition(pos: string) {
182-
const priority = this.#priority;
183220
const size = this.#type === 'horizontal' ? this.#width : this.#height;
184221

185222
let minPx = parseFloat(this.#min);
@@ -198,7 +235,7 @@ if (!hasSidePanel) {
198235
maxPx += size;
199236
}
200237

201-
posPx = priority === 'min' ? Math.max(minPx, Math.min(maxPx, posPx)) : Math.min(maxPx, Math.max(minPx, posPx));
238+
posPx = Math.max(minPx, Math.min(maxPx, posPx));
202239

203240
this.#pos = `${(100 * posPx) / size}%`;
204241

@@ -208,89 +245,3 @@ if (!hasSidePanel) {
208245

209246
customElements.define('resizable-panel', ResizablePanel);
210247
</script>
211-
212-
<style>
213-
.container {
214-
--divider-thickness: var(--thickness, 10px);
215-
--divider-after-size: 10px;
216-
217-
display: grid;
218-
position: relative;
219-
width: 100%;
220-
height: 100%;
221-
}
222-
223-
.container .panel {
224-
overflow: hidden;
225-
}
226-
227-
.container > .divider:hover {
228-
background-color: #8882;
229-
}
230-
231-
.container.horizontal > .panel {
232-
height: 100%;
233-
}
234-
235-
.container.vertical {
236-
grid-template-rows: var(--pos) 1fr;
237-
}
238-
239-
.container.horizontal {
240-
grid-template-columns: var(--pos) 1fr;
241-
}
242-
243-
.divider {
244-
position: absolute;
245-
z-index: 999;
246-
}
247-
248-
.divider::after {
249-
content: '';
250-
position: absolute;
251-
background-color: transparent;
252-
transition: background-color 0.2s ease;
253-
}
254-
255-
.horizontal > .divider {
256-
width: 0;
257-
height: 100%;
258-
left: var(--pos);
259-
cursor: ew-resize;
260-
padding: 0 calc(0.5 * var(--divider-thickness));
261-
transform: translate(calc(-0.5 * var(--divider-thickness)), 0);
262-
}
263-
264-
.vertical > .divider {
265-
width: 100%;
266-
height: 0;
267-
top: var(--pos);
268-
cursor: ns-resize;
269-
padding: calc(0.5 * var(--divider-thickness)) 0;
270-
transform: translate(0, calc(-0.5 * var(--divider-thickness)));
271-
}
272-
273-
.horizontal > .divider::after {
274-
left: 50%;
275-
top: 0;
276-
width: 0px;
277-
height: 100%;
278-
}
279-
280-
.vertical > .divider::after {
281-
top: 50%;
282-
left: 0;
283-
width: 100%;
284-
height: 1px;
285-
}
286-
287-
.horizontal > .divider:hover:after {
288-
left: calc(50% - var(--divider-after-size) / 2);
289-
width: var(--divider-after-size);
290-
}
291-
292-
.vertical > .divider:hover:after {
293-
top: calc(50% - var(--divider-after-size) / 2);
294-
height: var(--divider-after-size);
295-
}
296-
</style>

0 commit comments

Comments
 (0)