Skip to content

Commit 17237ca

Browse files
committed
feat: React version of M3ScrollRail, m3-scroll-box styles for custom scrollable container
1 parent 184592d commit 17237ca

File tree

11 files changed

+488
-151
lines changed

11 files changed

+488
-151
lines changed

m3-foundation/assets/stylesheets/components/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
@import 'rich-tooltip';
1313
@import 'ripple';
1414
@import 'scrim';
15+
@import 'scroll-box';
1516
@import 'scroll-rail';
1617
@import 'select';
1718
@import 'side-sheet';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.m3-scroll-box {
2+
$rail-thickness: var(--m3-scroll-rail-thickness, 12px);
3+
4+
display: flex;
5+
position: relative;
6+
7+
&_scroll-x { padding-bottom: $rail-thickness; }
8+
&_scroll-y { padding-right: $rail-thickness; }
9+
10+
&__content {
11+
width: 100%;
12+
max-height: 100%;
13+
-ms-overflow-style: none;
14+
scrollbar-width: none;
15+
}
16+
17+
&_scroll-x &__content { overflow-x: auto; }
18+
&_scroll-y &__content { overflow-y: auto; }
19+
}
Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
1-
@import "../../basics/motion";
1+
@import '../../basics/motion';
22

33
.m3-scroll-rail {
4+
$rail-thickness: var(--m3-scroll-rail-thickness, 12px);
5+
$slider-thickness: var(--m3-scroll-slider-thickness, 6px);
6+
$slider-thickness-on-hover: var(--m3-scroll-slider-thickness-on-hover, 8px);
7+
$slider-rounding: var(--m3-scroll-slider-rounding, 4px);
8+
49
display: flex;
5-
width: 12px;
10+
width: $rail-thickness;
611
height: 100%;
712
flex-direction: column;
813
align-items: center;
914
user-select: none;
1015
position: absolute;
16+
top: 0;
1117
right: 0;
1218

1319
&_disabled {
1420
width: 0;
21+
opacity: 0;
1522
}
1623

1724
&_horizontal {
1825
width: 100%;
19-
height: 12px;
26+
height: $rail-thickness;
2027
flex-direction: row;
28+
top: auto;
29+
left: 0;
2130
right: auto;
2231
bottom: 0;
2332
}
@@ -27,9 +36,9 @@
2736
}
2837

2938
&__slider {
30-
width: 6px;
39+
width: $slider-thickness;
3140
background: var(--m3-state-layers-on-surface-opacity-012);
32-
border-radius: 4px;
41+
border-radius: $slider-rounding;
3342
cursor: pointer;
3443
transition:
3544
m3-sys-motion-standard(width),
@@ -40,6 +49,7 @@
4049
}
4150

4251
&_horizontal &__slider {
52+
height: $slider-thickness;
4353
transition:
4454
m3-sys-motion-standard(height),
4555
m3-sys-motion-standard(background-color),
@@ -49,11 +59,12 @@
4959

5060
&:hover &__slider,
5161
&_active &__slider {
52-
width: 8px;
62+
width: $slider-thickness-on-hover;
5363
background: var(--m3-state-layers-on-surface-opacity-020);
5464
}
5565

56-
&_disabled &__slider {
57-
opacity: 0;
66+
&_horizontal:hover &__slider,
67+
&_horizontal#{&}_active &__slider {
68+
height: $slider-thickness-on-hover;
5869
}
5970
}

m3-foundation/lib/scroll.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
export const getScrollRatioX = (box: HTMLElement | null) => box && box.scrollWidth > 0
2+
? box.clientWidth / box.scrollWidth
3+
: 0
4+
5+
export const getScrollRatioY = (box: HTMLElement | null) => box && box.scrollHeight
6+
? box.clientHeight / box.scrollHeight
7+
: 0
8+
9+
export const getScrollRatio = (box: HTMLElement | null, horizontal: boolean) => horizontal
10+
? getScrollRatioX(box)
11+
: getScrollRatioY(box)
12+
13+
export const getScrollDistanceX = (box: HTMLElement | null) => box ? box.scrollWidth - box.clientWidth : 0
14+
export const getScrollDistanceY = (box: HTMLElement | null) => box ? box.scrollHeight - box.clientHeight : 0
15+
export const getScrollDistance = (box: HTMLElement | null, horizontal: boolean) => horizontal
16+
? getScrollDistanceX(box)
17+
: getScrollDistanceY(box)
18+
19+
export const getClientWidth = (box: HTMLElement | null) => box ? box.clientWidth : 0
20+
export const getClientHeight = (box: HTMLElement | null) => box ? box.clientHeight : 0
21+
export const getClientSize = (box: HTMLElement | null, horizontal: boolean) => horizontal
22+
? getClientWidth(box)
23+
: getClientHeight(box)
24+
25+
export const getSliderWidth = (slider: HTMLElement | null) => slider ? parseFloat(slider.style.width) : 0
26+
export const getSliderHeight = (slider: HTMLElement | null) => slider ? parseFloat(slider.style.height) : 0
27+
export const getSliderSize = (slider: HTMLElement | null, horizontal: boolean) => horizontal
28+
? getSliderWidth(slider)
29+
: getSliderHeight(slider)
30+
31+
export const syncSlider = (
32+
box: HTMLElement,
33+
slider: HTMLElement,
34+
horizontal: boolean
35+
) => {
36+
const scrollDistance = getScrollDistance(box, horizontal)
37+
const enabled = scrollDistance > 0
38+
39+
if (enabled) {
40+
const sliderLength = getClientSize(box, horizontal) * getScrollRatio(box, horizontal)
41+
const sliderDistance = getClientSize(box, horizontal) - sliderLength
42+
43+
if (horizontal) {
44+
slider.style.width = sliderLength + 'px'
45+
slider.style.height = ''
46+
slider.style.left = box.scrollLeft * sliderDistance / scrollDistance + 'px'
47+
slider.style.top = ''
48+
} else {
49+
slider.style.width = ''
50+
slider.style.height = sliderLength + 'px'
51+
slider.style.left = ''
52+
slider.style.top = box.scrollTop * sliderDistance / scrollDistance + 'px'
53+
}
54+
}
55+
56+
return enabled
57+
}
58+
59+
export const createRail = (el: HTMLElement, options: {
60+
horizontal?: boolean
61+
disabled?: boolean
62+
onDragStart?: () => void;
63+
onDragEnd?: () => void;
64+
onToggle?: (active: boolean) => void;
65+
}) => {
66+
let box: HTMLElement | null = null
67+
let observer: ResizeObserver | null = null
68+
69+
let dragging = false
70+
let horizontal = options.horizontal ?? false
71+
let disabled = options.disabled ?? false
72+
73+
const slider = el.querySelector<HTMLElement>('.m3-scroll-rail__slider')
74+
if (!slider) {
75+
throw new Error('[@modulify/m3-foundation::M3ScrollRail] Slider element not found')
76+
}
77+
78+
let lastPageX = 0
79+
let lastPageY = 0
80+
81+
const getPointerOffsetX = (event: MouseEvent) => event.pageX - lastPageX
82+
const getPointerOffsetY = (event: MouseEvent) => event.pageY - lastPageY
83+
const getPointerOffset = (event: MouseEvent) => horizontal
84+
? getPointerOffsetX(event)
85+
: getPointerOffsetY(event)
86+
87+
const onMousedown = (event: MouseEvent) => {
88+
dragging = true
89+
options.onDragStart?.()
90+
lastPageY = event.pageY
91+
lastPageX = event.pageX
92+
}
93+
94+
const onMousemove = (event: MouseEvent) => {
95+
if (!box || !dragging) {
96+
return
97+
}
98+
99+
const scrollDistance = getScrollDistance(box, horizontal)
100+
const sliderDistance = getClientSize(box, horizontal) - getSliderSize(slider, horizontal)
101+
102+
if (scrollDistance > 0 && sliderDistance > 0) {
103+
const pointerOffset = getPointerOffset(event)
104+
if (horizontal) {
105+
box.scrollLeft = (slider.offsetLeft + pointerOffset) * scrollDistance / sliderDistance
106+
slider.style.left = (box.scrollLeft * sliderDistance / scrollDistance) + 'px'
107+
slider.style.top = ''
108+
} else {
109+
box.scrollTop = (slider.offsetTop + pointerOffset) * scrollDistance / sliderDistance
110+
slider.style.left = ''
111+
slider.style.top = (box.scrollTop * sliderDistance / scrollDistance) + 'px'
112+
}
113+
}
114+
115+
lastPageX = event.pageX
116+
lastPageY = event.pageY
117+
}
118+
119+
const onMouseup = () => {
120+
dragging = false
121+
options.onDragEnd?.()
122+
}
123+
124+
const subscribe = (el: HTMLElement) => {
125+
observer = new ResizeObserver(() => sync())
126+
observer.observe(el)
127+
128+
el.addEventListener('scroll', sync, { passive: true })
129+
}
130+
131+
const unsubscribe = () => {
132+
observer?.disconnect()
133+
observer = null
134+
135+
box?.removeEventListener('scroll', sync)
136+
box = null
137+
}
138+
139+
const connect = () => {
140+
const parent = el.parentElement ?? null
141+
142+
if (parent === box) return
143+
if (box) { unsubscribe() }
144+
if (parent) { subscribe(parent) }
145+
146+
box = parent
147+
}
148+
149+
const sync = () => {
150+
connect()
151+
152+
if (box && !disabled) {
153+
const enabled = syncSlider(box, slider, horizontal)
154+
options.onToggle?.(enabled)
155+
} else {
156+
options.onToggle?.(false)
157+
}
158+
}
159+
160+
return {
161+
get horizontal () { return horizontal },
162+
set horizontal (isHorizontal: boolean) {
163+
const wasHorizontal = horizontal
164+
165+
horizontal = isHorizontal
166+
167+
if (!disabled && isHorizontal !== wasHorizontal) {
168+
sync()
169+
}
170+
},
171+
172+
get disabled () { return disabled },
173+
set disabled (disable: boolean) {
174+
if (!disable && disabled) {
175+
sync()
176+
}
177+
},
178+
179+
init () {
180+
slider.addEventListener('mousedown', onMousedown)
181+
window.addEventListener('mousemove', onMousemove)
182+
window.addEventListener('mouseup', onMouseup)
183+
sync()
184+
},
185+
186+
sync,
187+
188+
destroy () {
189+
slider.removeEventListener('mousedown', onMousedown)
190+
window.removeEventListener('mousemove', onMousemove)
191+
window.removeEventListener('mouseup', onMouseup)
192+
unsubscribe()
193+
},
194+
}
195+
}
196+
197+
export type ScrollRail = ReturnType<typeof createRail>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type {
2+
ForwardRefRenderFunction,
3+
HTMLAttributes,
4+
} from 'react'
5+
6+
import type { ScrollRail } from '@modulify/m3-foundation/lib/scroll'
7+
8+
import {
9+
forwardRef,
10+
useEffect,
11+
useImperativeHandle,
12+
useRef,
13+
} from 'react'
14+
15+
import {
16+
useRecord,
17+
useWatch,
18+
} from '@/hooks'
19+
20+
import { createRail } from '@modulify/m3-foundation/lib/scroll'
21+
import { toClassName } from '@/utils/styling'
22+
23+
export interface M3ScrollRailProps extends HTMLAttributes<HTMLElement> {
24+
horizontal?: boolean;
25+
disabled?: boolean;
26+
}
27+
28+
export interface M3ScrollRailMethods {
29+
sync (): void;
30+
}
31+
32+
const M3ScrollRail: ForwardRefRenderFunction<
33+
M3ScrollRailMethods,
34+
M3ScrollRailProps
35+
> = ({
36+
horizontal,
37+
disabled,
38+
className = '',
39+
...attrs
40+
}, ref) => {
41+
const root = useRef<HTMLDivElement | null>(null)
42+
const rail = useRef<ScrollRail | null>(null)
43+
44+
const state = useRecord({
45+
dragging: false,
46+
enabled: false,
47+
}, ['dragging', 'enabled'])
48+
49+
useWatch(horizontal, horizontal => {
50+
if (rail.current) { rail.current.horizontal = horizontal }
51+
})
52+
53+
useWatch(disabled, disabled => {
54+
if (rail.current) { rail.current.disabled = disabled }
55+
})
56+
57+
useImperativeHandle(ref, () => ({
58+
sync: () => rail.current?.sync(),
59+
}))
60+
61+
useEffect(() => {
62+
rail.current = createRail(root.current as HTMLElement, {
63+
horizontal,
64+
disabled,
65+
onDragStart: () => state.dragging = true,
66+
onDragEnd: () => state.dragging = false,
67+
onToggle: active => state.enabled = active,
68+
})
69+
rail.current.init()
70+
71+
return () => {
72+
rail.current?.destroy()
73+
rail.current = null
74+
}
75+
}, [])
76+
77+
return (
78+
<div
79+
ref={root}
80+
className={toClassName([className, {
81+
'm3-scroll-rail': true,
82+
'm3-scroll-rail_horizontal': horizontal,
83+
'm3-scroll-rail_active': state.dragging,
84+
'm3-scroll-rail_disabled': disabled || !state.enabled,
85+
}])}
86+
{...attrs}
87+
>
88+
<div className="m3-scroll-rail__slider" />
89+
</div>
90+
)
91+
}
92+
93+
export default forwardRef(M3ScrollRail)

0 commit comments

Comments
 (0)