@charcoal-ui/react 6.0.0-rc.1 → 6.0.0-rc.2
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.
- package/dist/components/Carousel/CarouselItem.d.ts +10 -0
- package/dist/components/Carousel/CarouselItem.d.ts.map +1 -0
- package/dist/components/Carousel/carouselStore.d.ts +25 -0
- package/dist/components/Carousel/carouselStore.d.ts.map +1 -0
- package/dist/components/Carousel/index.d.ts +61 -0
- package/dist/components/Carousel/index.d.ts.map +1 -0
- package/dist/components/Carousel/intersectionObserver.d.ts +7 -0
- package/dist/components/Carousel/intersectionObserver.d.ts.map +1 -0
- package/dist/components/Carousel/resizeObserver.d.ts +6 -0
- package/dist/components/Carousel/resizeObserver.d.ts.map +1 -0
- package/dist/components/Carousel/store.d.ts +7 -0
- package/dist/components/Carousel/store.d.ts.map +1 -0
- package/dist/components/Carousel/useCarouselScroller.d.ts +18 -0
- package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +275 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/layered.css +275 -0
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/components/Carousel/CarouselItem.tsx +56 -0
- package/src/components/Carousel/MIGRATION.md +117 -0
- package/src/components/Carousel/Migration.mdx +8 -0
- package/src/components/Carousel/__snapshots__/index.css.snap +274 -0
- package/src/components/Carousel/carouselStore.test.ts +38 -0
- package/src/components/Carousel/carouselStore.ts +48 -0
- package/src/components/Carousel/index.css +274 -0
- package/src/components/Carousel/index.story.tsx +169 -0
- package/src/components/Carousel/index.test.tsx +666 -0
- package/src/components/Carousel/index.tsx +278 -0
- package/src/components/Carousel/intersectionObserver.test.ts +68 -0
- package/src/components/Carousel/intersectionObserver.ts +58 -0
- package/src/components/Carousel/resizeObserver.test.ts +60 -0
- package/src/components/Carousel/resizeObserver.ts +35 -0
- package/src/components/Carousel/store.test.ts +25 -0
- package/src/components/Carousel/store.ts +27 -0
- package/src/components/Carousel/useCarouselScroller.ts +158 -0
- package/src/index.ts +12 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import './index.css'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
memo,
|
|
6
|
+
useCallback,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import { mergeProps, useFocusRing, useKeyboard } from 'react-aria'
|
|
14
|
+
import { useClassNames } from '../../_lib/useClassNames'
|
|
15
|
+
import IconButton from '../IconButton'
|
|
16
|
+
import { CarouselItem as CarouselSlide } from './CarouselItem'
|
|
17
|
+
import {
|
|
18
|
+
createCarouselStore,
|
|
19
|
+
INITIAL_CAROUSEL_STATE,
|
|
20
|
+
type CarouselState,
|
|
21
|
+
} from './carouselStore'
|
|
22
|
+
import { useCarouselScroller } from './useCarouselScroller'
|
|
23
|
+
|
|
24
|
+
const getServerSnapshot = (): CarouselState => INITIAL_CAROUSEL_STATE
|
|
25
|
+
|
|
26
|
+
export type CarouselItem = {
|
|
27
|
+
id: string
|
|
28
|
+
children: ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ScrollAlign = 'left' | 'center' | 'right'
|
|
32
|
+
|
|
33
|
+
export type ScrollSnapType = 'none' | 'proximity' | 'mandatory'
|
|
34
|
+
|
|
35
|
+
export type ScrollSnapAlign = 'center' | 'start'
|
|
36
|
+
|
|
37
|
+
export type ScrollSnap = Readonly<{
|
|
38
|
+
type?: ScrollSnapType
|
|
39
|
+
align?: ScrollSnapAlign
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
export type ScrollStepContext = Readonly<{
|
|
43
|
+
clientWidth: number
|
|
44
|
+
scrollWidth: number
|
|
45
|
+
scrollLeft: number
|
|
46
|
+
direction: 'prev' | 'next'
|
|
47
|
+
}>
|
|
48
|
+
|
|
49
|
+
export type ScrollStep = number | ((ctx: ScrollStepContext) => number)
|
|
50
|
+
|
|
51
|
+
// 命令的 API。ref 経由で初期位置へのリセットを公開する(react-sandbox 互換)。
|
|
52
|
+
export type CarouselHandlerRef = {
|
|
53
|
+
resetScroll: () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type CarouselProps = Readonly<{
|
|
57
|
+
className?: string
|
|
58
|
+
hasGradient?: boolean
|
|
59
|
+
fullWidth?: boolean
|
|
60
|
+
navigationButtons?: boolean
|
|
61
|
+
indicator?: boolean
|
|
62
|
+
size?: 'S' | 'M'
|
|
63
|
+
// 進む量。number は clientWidth に対する比率、function は進む px を直接返す。
|
|
64
|
+
scrollStep?: ScrollStep
|
|
65
|
+
// スクロールスナップ。未指定時は size 基準(M=none / S=mandatory)、align=center。
|
|
66
|
+
// M の none は sandbox 同等に 0.75×表示幅ちょうど進む(スナップで着地を吸着しない)。
|
|
67
|
+
scrollSnap?: ScrollSnap
|
|
68
|
+
// react-sandbox 互換のコールバック。
|
|
69
|
+
onScroll?: (left: number) => void
|
|
70
|
+
onResize?: (width: number) => void
|
|
71
|
+
onScrollStateChange?: (canScroll: boolean) => void
|
|
72
|
+
defaultScroll?: { align?: ScrollAlign; offset?: number }
|
|
73
|
+
items: CarouselItem[]
|
|
74
|
+
}>
|
|
75
|
+
|
|
76
|
+
type Direction = 'prev' | 'next'
|
|
77
|
+
|
|
78
|
+
const DEFAULT_SCROLL_STEP = 0.75
|
|
79
|
+
|
|
80
|
+
const NAV_ICON = {
|
|
81
|
+
prev: '24/Prev',
|
|
82
|
+
next: '24/Next',
|
|
83
|
+
} as const
|
|
84
|
+
|
|
85
|
+
type NavigationButtonProps = Readonly<{
|
|
86
|
+
direction: Direction
|
|
87
|
+
canScroll: boolean
|
|
88
|
+
onScroll: (direction: Direction) => void
|
|
89
|
+
}>
|
|
90
|
+
|
|
91
|
+
const CarouselNavigationButton = ({
|
|
92
|
+
direction,
|
|
93
|
+
canScroll,
|
|
94
|
+
onScroll,
|
|
95
|
+
}: NavigationButtonProps) => {
|
|
96
|
+
const handleClick = useCallback(() => {
|
|
97
|
+
onScroll(direction)
|
|
98
|
+
}, [onScroll, direction])
|
|
99
|
+
return (
|
|
100
|
+
<IconButton
|
|
101
|
+
variant="Overlay"
|
|
102
|
+
size="S"
|
|
103
|
+
icon={NAV_ICON[direction]}
|
|
104
|
+
aria-label={direction === 'prev' ? 'Previous' : 'Next'}
|
|
105
|
+
disabled={!canScroll}
|
|
106
|
+
onClick={handleClick}
|
|
107
|
+
className="charcoal-carousel__navigation__item"
|
|
108
|
+
data-direction={direction}
|
|
109
|
+
data-hidden={!canScroll}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type IndicatorItemProps = Readonly<{
|
|
115
|
+
index: number
|
|
116
|
+
isActive: boolean
|
|
117
|
+
onSelect: (index: number) => void
|
|
118
|
+
}>
|
|
119
|
+
|
|
120
|
+
const CarouselIndicatorItem = ({
|
|
121
|
+
index,
|
|
122
|
+
isActive,
|
|
123
|
+
onSelect,
|
|
124
|
+
}: IndicatorItemProps) => {
|
|
125
|
+
const handleClick = useCallback(() => {
|
|
126
|
+
onSelect(index)
|
|
127
|
+
}, [onSelect, index])
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
className="charcoal-carousel__indicator__item"
|
|
131
|
+
data-active={isActive}
|
|
132
|
+
aria-current={isActive || undefined}
|
|
133
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
134
|
+
onClick={handleClick}
|
|
135
|
+
/>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
140
|
+
{
|
|
141
|
+
size = 'M',
|
|
142
|
+
navigationButtons,
|
|
143
|
+
indicator,
|
|
144
|
+
hasGradient = false,
|
|
145
|
+
fullWidth = false,
|
|
146
|
+
scrollStep = DEFAULT_SCROLL_STEP,
|
|
147
|
+
scrollSnap,
|
|
148
|
+
onScroll,
|
|
149
|
+
onResize,
|
|
150
|
+
onScrollStateChange,
|
|
151
|
+
defaultScroll: { align = 'left', offset = 0 } = {},
|
|
152
|
+
...props
|
|
153
|
+
}: CarouselProps,
|
|
154
|
+
ref,
|
|
155
|
+
) {
|
|
156
|
+
const className = useClassNames('charcoal-carousel', props.className)
|
|
157
|
+
const showNavigationButtons = navigationButtons ?? size === 'M'
|
|
158
|
+
const showIndicator = indicator ?? size === 'S'
|
|
159
|
+
const snapType = scrollSnap?.type ?? (size === 'S' ? 'mandatory' : 'none')
|
|
160
|
+
const snapAlign = scrollSnap?.align ?? 'center'
|
|
161
|
+
|
|
162
|
+
const scrollerRef = useRef<HTMLDivElement>(null)
|
|
163
|
+
const [store] = useState(createCarouselStore)
|
|
164
|
+
|
|
165
|
+
const { scrollByStep, onItemResize, resetScroll } = useCarouselScroller(
|
|
166
|
+
scrollerRef,
|
|
167
|
+
store,
|
|
168
|
+
props.items.length,
|
|
169
|
+
{ align, offset, scrollStep, onScroll, onResize, onScrollStateChange },
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
useImperativeHandle(ref, () => ({ resetScroll }), [resetScroll])
|
|
173
|
+
|
|
174
|
+
const { activeIndex, canPrev, canNext } = useSyncExternalStore(
|
|
175
|
+
store.subscribe,
|
|
176
|
+
store.getSnapshot,
|
|
177
|
+
getServerSnapshot,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const scrollToItem = useCallback(
|
|
181
|
+
(index: number) => store.dispatch({ type: 'requestScroll', index }),
|
|
182
|
+
[store],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
// ←/→ でスクロール。コンテナにフォーカスがある時のみ。
|
|
186
|
+
const { keyboardProps } = useKeyboard({
|
|
187
|
+
onKeyDown: (e) => {
|
|
188
|
+
if (e.key === 'ArrowRight') {
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
scrollByStep('next')
|
|
191
|
+
} else if (e.key === 'ArrowLeft') {
|
|
192
|
+
e.preventDefault()
|
|
193
|
+
scrollByStep('prev')
|
|
194
|
+
} else {
|
|
195
|
+
e.continuePropagation()
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const {
|
|
201
|
+
focusProps: scrollerFocusProps,
|
|
202
|
+
isFocusVisible: scrollerFocusVisible,
|
|
203
|
+
} = useFocusRing()
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
className={className}
|
|
208
|
+
data-size={size}
|
|
209
|
+
data-has-gradient={hasGradient}
|
|
210
|
+
data-full-width={fullWidth}
|
|
211
|
+
data-indicator={showIndicator}
|
|
212
|
+
data-scroll-snap-type={snapType}
|
|
213
|
+
data-scroll-snap-align={snapAlign}
|
|
214
|
+
data-can-prev={canPrev}
|
|
215
|
+
data-can-next={canNext}
|
|
216
|
+
role="region"
|
|
217
|
+
aria-roledescription="carousel"
|
|
218
|
+
aria-label="Carousel"
|
|
219
|
+
>
|
|
220
|
+
<div className="charcoal-carousel__viewport">
|
|
221
|
+
<div
|
|
222
|
+
{...mergeProps(scrollerFocusProps, keyboardProps)}
|
|
223
|
+
ref={scrollerRef}
|
|
224
|
+
className="charcoal-carousel__scroller"
|
|
225
|
+
tabIndex={0}
|
|
226
|
+
data-focus-visible={scrollerFocusVisible || undefined}
|
|
227
|
+
>
|
|
228
|
+
{props.items.map((item, i) => (
|
|
229
|
+
<CarouselSlide
|
|
230
|
+
key={item.id}
|
|
231
|
+
index={i}
|
|
232
|
+
store={store}
|
|
233
|
+
onResize={onItemResize}
|
|
234
|
+
>
|
|
235
|
+
{item.children}
|
|
236
|
+
</CarouselSlide>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div
|
|
241
|
+
className="charcoal-carousel__navigation"
|
|
242
|
+
data-visible={showNavigationButtons}
|
|
243
|
+
aria-hidden={!showNavigationButtons}
|
|
244
|
+
>
|
|
245
|
+
<CarouselNavigationButton
|
|
246
|
+
direction="prev"
|
|
247
|
+
canScroll={canPrev}
|
|
248
|
+
onScroll={scrollByStep}
|
|
249
|
+
/>
|
|
250
|
+
<CarouselNavigationButton
|
|
251
|
+
direction="next"
|
|
252
|
+
canScroll={canNext}
|
|
253
|
+
onScroll={scrollByStep}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div
|
|
259
|
+
className="charcoal-carousel__indicator"
|
|
260
|
+
data-visible={showIndicator}
|
|
261
|
+
aria-hidden={!showIndicator}
|
|
262
|
+
>
|
|
263
|
+
{props.items.map((item, i) => (
|
|
264
|
+
<CarouselIndicatorItem
|
|
265
|
+
key={item.id}
|
|
266
|
+
index={i}
|
|
267
|
+
isActive={i === activeIndex}
|
|
268
|
+
onSelect={scrollToItem}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
Carousel.displayName = 'Carousel'
|
|
277
|
+
|
|
278
|
+
export default memo(Carousel)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { observeCenter } from './intersectionObserver'
|
|
3
|
+
|
|
4
|
+
type MockIO = {
|
|
5
|
+
cb: IntersectionObserverCallback
|
|
6
|
+
observe: ReturnType<typeof vi.fn>
|
|
7
|
+
unobserve: ReturnType<typeof vi.fn>
|
|
8
|
+
disconnect: ReturnType<typeof vi.fn>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('observeCenter', () => {
|
|
12
|
+
const orig = globalThis.IntersectionObserver
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.IntersectionObserver = orig
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('IntersectionObserver 非対応なら no-op(throw しない)', () => {
|
|
18
|
+
globalThis.IntersectionObserver =
|
|
19
|
+
undefined as unknown as typeof globalThis.IntersectionObserver
|
|
20
|
+
const el = document.createElement('div')
|
|
21
|
+
expect(() => observeCenter(el, () => undefined)()).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('同一 root の el は observer を共有し、中央交差で対象の onEnter だけ呼ぶ', () => {
|
|
25
|
+
const instances: MockIO[] = []
|
|
26
|
+
globalThis.IntersectionObserver = vi.fn(
|
|
27
|
+
(cb: IntersectionObserverCallback) => {
|
|
28
|
+
const inst: MockIO = {
|
|
29
|
+
cb,
|
|
30
|
+
observe: vi.fn(),
|
|
31
|
+
unobserve: vi.fn(),
|
|
32
|
+
disconnect: vi.fn(),
|
|
33
|
+
}
|
|
34
|
+
instances.push(inst)
|
|
35
|
+
return inst
|
|
36
|
+
},
|
|
37
|
+
) as unknown as typeof globalThis.IntersectionObserver
|
|
38
|
+
|
|
39
|
+
const root = document.createElement('div')
|
|
40
|
+
const a = document.createElement('div')
|
|
41
|
+
const b = document.createElement('div')
|
|
42
|
+
root.append(a, b)
|
|
43
|
+
const onA = vi.fn()
|
|
44
|
+
const onB = vi.fn()
|
|
45
|
+
|
|
46
|
+
const offA = observeCenter(a, onA)
|
|
47
|
+
const offB = observeCenter(b, onB)
|
|
48
|
+
|
|
49
|
+
// 同一 root(親)なので observer は1つだけ
|
|
50
|
+
expect(instances).toHaveLength(1)
|
|
51
|
+
expect(instances[0].observe).toHaveBeenCalledWith(a)
|
|
52
|
+
expect(instances[0].observe).toHaveBeenCalledWith(b)
|
|
53
|
+
|
|
54
|
+
// a が中央交差 → onA だけ
|
|
55
|
+
instances[0].cb(
|
|
56
|
+
[{ isIntersecting: true, target: a } as IntersectionObserverEntry],
|
|
57
|
+
instances[0] as unknown as IntersectionObserver,
|
|
58
|
+
)
|
|
59
|
+
expect(onA).toHaveBeenCalledTimes(1)
|
|
60
|
+
expect(onB).not.toHaveBeenCalled()
|
|
61
|
+
|
|
62
|
+
// 最後の el を外すと disconnect
|
|
63
|
+
offA()
|
|
64
|
+
expect(instances[0].disconnect).not.toHaveBeenCalled()
|
|
65
|
+
offB()
|
|
66
|
+
expect(instances[0].disconnect).toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 中央検出(左右 50% を除外)。root は item の親(scroller)から導出する。
|
|
2
|
+
const CENTER_ROOT_MARGIN = '0px -50% 0px -50%'
|
|
3
|
+
|
|
4
|
+
type CenterEntry = {
|
|
5
|
+
observer: IntersectionObserver
|
|
6
|
+
callbacks: Map<Element, () => void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// root(= el.parentElement)ごとに observer を1つ共有する。
|
|
10
|
+
const byRoot = new Map<Element, CenterEntry>()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `el` がその親(= scroller)の中央ラインに重なったら `onEnter` を呼ぶ。同一 root の
|
|
14
|
+
* IntersectionObserver を共有する singleton。返り値で監視解除(最後の1つで disconnect)。
|
|
15
|
+
* SSR / 非対応環境では no-op。
|
|
16
|
+
*/
|
|
17
|
+
export function observeCenter(
|
|
18
|
+
el: HTMLElement,
|
|
19
|
+
onEnter: () => void,
|
|
20
|
+
): () => void {
|
|
21
|
+
// window で SSR を弾く。polyfill が global に IntersectionObserver を生やしていても
|
|
22
|
+
// サーバー上では observe しない。
|
|
23
|
+
if (
|
|
24
|
+
typeof window === 'undefined' ||
|
|
25
|
+
typeof IntersectionObserver === 'undefined'
|
|
26
|
+
)
|
|
27
|
+
return () => undefined
|
|
28
|
+
const root = el.parentElement
|
|
29
|
+
if (!root) return () => undefined
|
|
30
|
+
|
|
31
|
+
let entry = byRoot.get(root)
|
|
32
|
+
if (!entry) {
|
|
33
|
+
const callbacks = new Map<Element, () => void>()
|
|
34
|
+
const observer = new IntersectionObserver(
|
|
35
|
+
(entries) => {
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (e.isIntersecting) callbacks.get(e.target)?.()
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ root, rootMargin: CENTER_ROOT_MARGIN, threshold: 0 },
|
|
41
|
+
)
|
|
42
|
+
entry = { observer, callbacks }
|
|
43
|
+
byRoot.set(root, entry)
|
|
44
|
+
}
|
|
45
|
+
entry.callbacks.set(el, onEnter)
|
|
46
|
+
entry.observer.observe(el)
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
const e = byRoot.get(root)
|
|
50
|
+
if (!e) return
|
|
51
|
+
e.callbacks.delete(el)
|
|
52
|
+
e.observer.unobserve(el)
|
|
53
|
+
if (e.callbacks.size === 0) {
|
|
54
|
+
e.observer.disconnect()
|
|
55
|
+
byRoot.delete(root)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { observeResize } from './resizeObserver'
|
|
3
|
+
|
|
4
|
+
type MockRO = {
|
|
5
|
+
cb: ResizeObserverCallback
|
|
6
|
+
observe: ReturnType<typeof vi.fn>
|
|
7
|
+
unobserve: ReturnType<typeof vi.fn>
|
|
8
|
+
disconnect: ReturnType<typeof vi.fn>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('observeResize', () => {
|
|
12
|
+
const orig = globalThis.ResizeObserver
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.ResizeObserver = orig
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('ResizeObserver 非対応なら no-op(throw しない)', () => {
|
|
18
|
+
globalThis.ResizeObserver =
|
|
19
|
+
undefined as unknown as typeof globalThis.ResizeObserver
|
|
20
|
+
const el = document.createElement('div')
|
|
21
|
+
expect(() => observeResize(el, () => undefined)()).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('単一の observer を共有し、対象のサイズ変化で cb を呼ぶ', () => {
|
|
25
|
+
const instances: MockRO[] = []
|
|
26
|
+
globalThis.ResizeObserver = vi.fn((cb: ResizeObserverCallback) => {
|
|
27
|
+
const inst: MockRO = {
|
|
28
|
+
cb,
|
|
29
|
+
observe: vi.fn(),
|
|
30
|
+
unobserve: vi.fn(),
|
|
31
|
+
disconnect: vi.fn(),
|
|
32
|
+
}
|
|
33
|
+
instances.push(inst)
|
|
34
|
+
return inst
|
|
35
|
+
}) as unknown as typeof globalThis.ResizeObserver
|
|
36
|
+
|
|
37
|
+
const a = document.createElement('div')
|
|
38
|
+
const b = document.createElement('div')
|
|
39
|
+
const onA = vi.fn()
|
|
40
|
+
const onB = vi.fn()
|
|
41
|
+
|
|
42
|
+
const offA = observeResize(a, onA)
|
|
43
|
+
const offB = observeResize(b, onB)
|
|
44
|
+
|
|
45
|
+
// 単一 observer を共有
|
|
46
|
+
expect(instances).toHaveLength(1)
|
|
47
|
+
|
|
48
|
+
instances[0].cb(
|
|
49
|
+
[{ target: a } as ResizeObserverEntry],
|
|
50
|
+
instances[0] as unknown as ResizeObserver,
|
|
51
|
+
)
|
|
52
|
+
expect(onA).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(onB).not.toHaveBeenCalled()
|
|
54
|
+
|
|
55
|
+
offA()
|
|
56
|
+
expect(instances[0].disconnect).not.toHaveBeenCalled()
|
|
57
|
+
offB()
|
|
58
|
+
expect(instances[0].disconnect).toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 全 el で単一の ResizeObserver を共有する singleton。
|
|
2
|
+
let observer: ResizeObserver | null = null
|
|
3
|
+
const callbacks = new Map<Element, () => void>()
|
|
4
|
+
|
|
5
|
+
function getObserver(): ResizeObserver | null {
|
|
6
|
+
// window で SSR を弾く。polyfill が global に ResizeObserver を生やしていても
|
|
7
|
+
// サーバー上では observe しない。
|
|
8
|
+
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined')
|
|
9
|
+
return null
|
|
10
|
+
if (!observer) {
|
|
11
|
+
observer = new ResizeObserver((entries) => {
|
|
12
|
+
for (const e of entries) callbacks.get(e.target)?.()
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
return observer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `el` のサイズ変化で `cb` を呼ぶ。全要素で単一の ResizeObserver を共有する singleton。
|
|
20
|
+
* 返り値で監視解除(最後の1つで disconnect)。SSR / 非対応環境では no-op。
|
|
21
|
+
*/
|
|
22
|
+
export function observeResize(el: Element, cb: () => void): () => void {
|
|
23
|
+
const obs = getObserver()
|
|
24
|
+
if (!obs) return () => undefined
|
|
25
|
+
callbacks.set(el, cb)
|
|
26
|
+
obs.observe(el)
|
|
27
|
+
return () => {
|
|
28
|
+
callbacks.delete(el)
|
|
29
|
+
obs.unobserve(el)
|
|
30
|
+
if (callbacks.size === 0) {
|
|
31
|
+
obs.disconnect()
|
|
32
|
+
observer = null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { createStore } from './store'
|
|
3
|
+
|
|
4
|
+
describe('createStore', () => {
|
|
5
|
+
it('reducer の結果を getSnapshot で返す', () => {
|
|
6
|
+
const s = createStore(
|
|
7
|
+
(n: number, a: { type: 'inc' }) => (a.type === 'inc' ? n + 1 : n),
|
|
8
|
+
0,
|
|
9
|
+
)
|
|
10
|
+
expect(s.getSnapshot()).toBe(0)
|
|
11
|
+
s.dispatch({ type: 'inc' })
|
|
12
|
+
expect(s.getSnapshot()).toBe(1)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('dispatch ごとに listener を呼び、unsubscribe で止まる', () => {
|
|
16
|
+
const s = createStore((n: number, _a: { type: 'inc' }) => n + 1, 0)
|
|
17
|
+
const fn = vi.fn()
|
|
18
|
+
const off = s.subscribe(fn)
|
|
19
|
+
s.dispatch({ type: 'inc' })
|
|
20
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
21
|
+
off()
|
|
22
|
+
s.dispatch({ type: 'inc' })
|
|
23
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Store<State, Action> = Readonly<{
|
|
2
|
+
dispatch: (action: Action) => void
|
|
3
|
+
subscribe: (listener: () => void) => () => void
|
|
4
|
+
getSnapshot: () => State
|
|
5
|
+
}>
|
|
6
|
+
|
|
7
|
+
export function createStore<State, Action>(
|
|
8
|
+
reducer: (state: State, action: Action) => State,
|
|
9
|
+
initialState: State,
|
|
10
|
+
): Store<State, Action> {
|
|
11
|
+
let state = initialState
|
|
12
|
+
const listeners = new Set<() => void>()
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
dispatch(action) {
|
|
16
|
+
state = reducer(state, action)
|
|
17
|
+
for (const listener of listeners) listener()
|
|
18
|
+
},
|
|
19
|
+
subscribe(listener) {
|
|
20
|
+
listeners.add(listener)
|
|
21
|
+
return () => {
|
|
22
|
+
listeners.delete(listener)
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
getSnapshot: () => state,
|
|
26
|
+
}
|
|
27
|
+
}
|