@charcoal-ui/react 6.0.0-rc.1 → 6.0.0-rc.3
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/components/Icon/index.d.ts +1 -1
- package/dist/components/Icon/index.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +319 -1
- 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 +319 -1
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/fixtures/legacy-v1-index.css +1836 -0
- package/src/__tests__/token-v1-compat.test.ts +118 -0
- package/src/__tests__/token-v1-remap.test.ts +133 -0
- 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/components/Icon/index.tsx +2 -1
- package/src/components/Modal/__snapshots__/index.css.snap +1 -1
- package/src/components/Modal/index.css +1 -1
- package/src/index.ts +12 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { createRef } from 'react'
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
4
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import Carousel, { type CarouselItem, type CarouselHandlerRef } from '.'
|
|
6
|
+
|
|
7
|
+
const items: CarouselItem[] = Array.from({ length: 6 }, (_, i) => ({
|
|
8
|
+
id: `item-${i}`,
|
|
9
|
+
children: <div data-testid={`slide-${i}`}>Slide {i}</div>,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
function mockScrollerGeometry(
|
|
13
|
+
el: HTMLElement,
|
|
14
|
+
{ scrollLeft = 0, scrollWidth = 2400, clientWidth = 800 } = {},
|
|
15
|
+
) {
|
|
16
|
+
Object.defineProperty(el, 'scrollLeft', {
|
|
17
|
+
get: () => scrollLeft,
|
|
18
|
+
set: vi.fn(),
|
|
19
|
+
configurable: true,
|
|
20
|
+
})
|
|
21
|
+
Object.defineProperty(el, 'scrollWidth', {
|
|
22
|
+
value: scrollWidth,
|
|
23
|
+
configurable: true,
|
|
24
|
+
})
|
|
25
|
+
Object.defineProperty(el, 'clientWidth', {
|
|
26
|
+
value: clientWidth,
|
|
27
|
+
configurable: true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < el.children.length; i++) {
|
|
31
|
+
const child = el.children[i] as HTMLElement
|
|
32
|
+
Object.defineProperty(child, 'offsetLeft', {
|
|
33
|
+
value: i * 400,
|
|
34
|
+
configurable: true,
|
|
35
|
+
})
|
|
36
|
+
Object.defineProperty(child, 'offsetWidth', {
|
|
37
|
+
value: 380,
|
|
38
|
+
configurable: true,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getScroller() {
|
|
44
|
+
return document.querySelector('.charcoal-carousel__scroller') as HTMLElement
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 各 observer が「observe した要素 → コールバック」を覚え、テストから任意要素の
|
|
48
|
+
// intersection を発火できるようにする(新設計は item ごとに observer を持つ)。
|
|
49
|
+
type IOEntry = { cb: (entries: unknown[]) => void; els: Set<Element> }
|
|
50
|
+
const ioRegistry: IOEntry[] = []
|
|
51
|
+
|
|
52
|
+
function installIOMock() {
|
|
53
|
+
ioRegistry.length = 0
|
|
54
|
+
const orig = globalThis.IntersectionObserver
|
|
55
|
+
globalThis.IntersectionObserver = class {
|
|
56
|
+
private entry: IOEntry
|
|
57
|
+
constructor(cb: (entries: unknown[]) => void) {
|
|
58
|
+
this.entry = { cb, els: new Set() }
|
|
59
|
+
ioRegistry.push(this.entry)
|
|
60
|
+
}
|
|
61
|
+
observe = (el: Element) => this.entry.els.add(el)
|
|
62
|
+
unobserve = (el: Element) => this.entry.els.delete(el)
|
|
63
|
+
disconnect = () => this.entry.els.clear()
|
|
64
|
+
takeRecords = () => []
|
|
65
|
+
} as unknown as typeof globalThis.IntersectionObserver
|
|
66
|
+
return () => {
|
|
67
|
+
globalThis.IntersectionObserver = orig
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fireIntersect(el: Element) {
|
|
72
|
+
for (const e of ioRegistry) {
|
|
73
|
+
if (e.els.has(el)) e.cb([{ isIntersecting: true, target: el }])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('Carousel', () => {
|
|
78
|
+
describe('rendering', () => {
|
|
79
|
+
it('renders all items', () => {
|
|
80
|
+
render(<Carousel items={items} />)
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
expect(screen.getByTestId(`slide-${i}`)).toBeInTheDocument()
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('has carousel ARIA attributes', () => {
|
|
87
|
+
render(<Carousel items={items} />)
|
|
88
|
+
const region = screen.getByRole('region')
|
|
89
|
+
expect(region).toHaveAttribute('aria-roledescription', 'carousel')
|
|
90
|
+
expect(region).toHaveAttribute('aria-label', 'Carousel')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('Size M (default)', () => {
|
|
95
|
+
it('shows navigation buttons', () => {
|
|
96
|
+
render(<Carousel items={items} size="M" />)
|
|
97
|
+
expect(
|
|
98
|
+
screen.getByRole('button', { name: 'Previous' }),
|
|
99
|
+
).toBeInTheDocument()
|
|
100
|
+
expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('hides indicator by default', () => {
|
|
104
|
+
const { container } = render(<Carousel items={items} size="M" />)
|
|
105
|
+
const indicator = container.querySelector('.charcoal-carousel__indicator')
|
|
106
|
+
expect(indicator).toHaveAttribute('data-visible', 'false')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('Size S', () => {
|
|
111
|
+
it('hides navigation buttons by default', () => {
|
|
112
|
+
const { container } = render(<Carousel items={items} size="S" />)
|
|
113
|
+
const nav = container.querySelector('.charcoal-carousel__navigation')
|
|
114
|
+
expect(nav).toHaveAttribute('data-visible', 'false')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('navigation button state', () => {
|
|
119
|
+
it('disables prev button at scroll start', () => {
|
|
120
|
+
render(<Carousel items={items} />)
|
|
121
|
+
const prev = screen.getByRole('button', { name: 'Previous' })
|
|
122
|
+
expect(prev).toBeDisabled()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('enables next button when scrollable content exists', () => {
|
|
126
|
+
render(<Carousel items={items} />)
|
|
127
|
+
const scroller = getScroller()
|
|
128
|
+
mockScrollerGeometry(scroller, { scrollLeft: 0 })
|
|
129
|
+
act(() => {
|
|
130
|
+
fireEvent.scroll(scroller)
|
|
131
|
+
})
|
|
132
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
133
|
+
expect(next).not.toBeDisabled()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('scrollByStep (0.75x viewport)', () => {
|
|
138
|
+
it('calls scrollBy with 0.75x viewport width on next button click', () => {
|
|
139
|
+
render(<Carousel items={items} />)
|
|
140
|
+
const scroller = getScroller()
|
|
141
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
142
|
+
act(() => {
|
|
143
|
+
fireEvent.scroll(scroller)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const scrollBySpy = vi.fn()
|
|
147
|
+
scroller.scrollBy = scrollBySpy
|
|
148
|
+
|
|
149
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
150
|
+
fireEvent.click(next)
|
|
151
|
+
|
|
152
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
153
|
+
left: 600,
|
|
154
|
+
behavior: 'smooth',
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('calls scrollBy with negative 0.75x on prev button click', () => {
|
|
159
|
+
render(<Carousel items={items} />)
|
|
160
|
+
const scroller = getScroller()
|
|
161
|
+
mockScrollerGeometry(scroller, { scrollLeft: 400 })
|
|
162
|
+
act(() => {
|
|
163
|
+
fireEvent.scroll(scroller)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const scrollBySpy = vi.fn()
|
|
167
|
+
scroller.scrollBy = scrollBySpy
|
|
168
|
+
|
|
169
|
+
const prev = screen.getByRole('button', { name: 'Previous' })
|
|
170
|
+
fireEvent.click(prev)
|
|
171
|
+
|
|
172
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
173
|
+
left: -600,
|
|
174
|
+
behavior: 'smooth',
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('respects custom scrollStep ratio (number)', () => {
|
|
179
|
+
render(<Carousel items={items} scrollStep={0.5} />)
|
|
180
|
+
const scroller = getScroller()
|
|
181
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
182
|
+
act(() => {
|
|
183
|
+
fireEvent.scroll(scroller)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const scrollBySpy = vi.fn()
|
|
187
|
+
scroller.scrollBy = scrollBySpy
|
|
188
|
+
|
|
189
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
190
|
+
fireEvent.click(next)
|
|
191
|
+
|
|
192
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
193
|
+
left: 400,
|
|
194
|
+
behavior: 'smooth',
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('respects custom scrollStep function (returns px)', () => {
|
|
199
|
+
const scrollStep = vi.fn(
|
|
200
|
+
({ clientWidth }: { clientWidth: number }) => clientWidth - 48,
|
|
201
|
+
)
|
|
202
|
+
render(<Carousel items={items} scrollStep={scrollStep} />)
|
|
203
|
+
const scroller = getScroller()
|
|
204
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
205
|
+
act(() => {
|
|
206
|
+
fireEvent.scroll(scroller)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const scrollBySpy = vi.fn()
|
|
210
|
+
scroller.scrollBy = scrollBySpy
|
|
211
|
+
|
|
212
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
213
|
+
fireEvent.click(next)
|
|
214
|
+
|
|
215
|
+
expect(scrollStep).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
clientWidth: 800,
|
|
218
|
+
direction: 'next',
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
// clientWidth(800) - 48 = 752
|
|
222
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
223
|
+
left: 752,
|
|
224
|
+
behavior: 'smooth',
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('keyboard navigation', () => {
|
|
230
|
+
it('scrolls next by 0.75x viewport on ArrowRight', () => {
|
|
231
|
+
render(<Carousel items={items} />)
|
|
232
|
+
const scroller = getScroller()
|
|
233
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
234
|
+
act(() => {
|
|
235
|
+
fireEvent.scroll(scroller)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const scrollBySpy = vi.fn()
|
|
239
|
+
scroller.scrollBy = scrollBySpy
|
|
240
|
+
|
|
241
|
+
fireEvent.keyDown(scroller, { key: 'ArrowRight' })
|
|
242
|
+
|
|
243
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
244
|
+
left: 600,
|
|
245
|
+
behavior: 'smooth',
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('scrolls prev by negative 0.75x viewport on ArrowLeft', () => {
|
|
250
|
+
render(<Carousel items={items} />)
|
|
251
|
+
const scroller = getScroller()
|
|
252
|
+
mockScrollerGeometry(scroller, { scrollLeft: 400 })
|
|
253
|
+
act(() => {
|
|
254
|
+
fireEvent.scroll(scroller)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const scrollBySpy = vi.fn()
|
|
258
|
+
scroller.scrollBy = scrollBySpy
|
|
259
|
+
|
|
260
|
+
fireEvent.keyDown(scroller, { key: 'ArrowLeft' })
|
|
261
|
+
|
|
262
|
+
expect(scrollBySpy).toHaveBeenCalledWith({
|
|
263
|
+
left: -600,
|
|
264
|
+
behavior: 'smooth',
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('ignores unrelated keys', () => {
|
|
269
|
+
render(<Carousel items={items} />)
|
|
270
|
+
const scroller = getScroller()
|
|
271
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
272
|
+
|
|
273
|
+
const scrollBySpy = vi.fn()
|
|
274
|
+
scroller.scrollBy = scrollBySpy
|
|
275
|
+
|
|
276
|
+
fireEvent.keyDown(scroller, { key: 'Enter' })
|
|
277
|
+
|
|
278
|
+
expect(scrollBySpy).not.toHaveBeenCalled()
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('indicator navigation', () => {
|
|
283
|
+
it('scrolls the clicked item into view via the store (item self-scrolls)', () => {
|
|
284
|
+
// jsdom には scrollIntoView が無いのでモックを定義する。
|
|
285
|
+
const scrollIntoView = vi.fn()
|
|
286
|
+
Element.prototype.scrollIntoView = scrollIntoView
|
|
287
|
+
const { container } = render(<Carousel items={items} size="S" />)
|
|
288
|
+
|
|
289
|
+
const dots = container.querySelectorAll(
|
|
290
|
+
'.charcoal-carousel__indicator__item',
|
|
291
|
+
)
|
|
292
|
+
act(() => {
|
|
293
|
+
fireEvent.click(dots[2])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// 対象 item だけが自分自身に scrollIntoView を発行する。
|
|
297
|
+
expect(scrollIntoView).toHaveBeenCalledTimes(1)
|
|
298
|
+
expect(scrollIntoView).toHaveBeenCalledWith({
|
|
299
|
+
behavior: 'smooth',
|
|
300
|
+
inline: 'center',
|
|
301
|
+
block: 'nearest',
|
|
302
|
+
})
|
|
303
|
+
const itemEls = container.querySelectorAll('.charcoal-carousel__item')
|
|
304
|
+
expect(scrollIntoView.mock.contexts[0]).toBe(itemEls[2])
|
|
305
|
+
|
|
306
|
+
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('activeIndex (item self-detection)', () => {
|
|
311
|
+
it('item が中央に入ると activeIndex が更新され indicator に反映される', () => {
|
|
312
|
+
const restore = installIOMock()
|
|
313
|
+
const { container } = render(
|
|
314
|
+
<Carousel items={items} size="M" indicator />,
|
|
315
|
+
)
|
|
316
|
+
const itemEls = container.querySelectorAll('.charcoal-carousel__item')
|
|
317
|
+
act(() => {
|
|
318
|
+
fireIntersect(itemEls[2])
|
|
319
|
+
})
|
|
320
|
+
const dots = container.querySelectorAll(
|
|
321
|
+
'.charcoal-carousel__indicator__item',
|
|
322
|
+
)
|
|
323
|
+
expect(dots[2]).toHaveAttribute('data-active', 'true')
|
|
324
|
+
restore()
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('scroll-state data attributes (mask)', () => {
|
|
329
|
+
it('reflects canPrev/canNext on the root element', () => {
|
|
330
|
+
const { container } = render(<Carousel items={items} hasGradient />)
|
|
331
|
+
const root = container.querySelector('.charcoal-carousel')
|
|
332
|
+
const scroller = getScroller()
|
|
333
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
334
|
+
act(() => {
|
|
335
|
+
fireEvent.scroll(scroller)
|
|
336
|
+
})
|
|
337
|
+
expect(root).toHaveAttribute('data-can-prev', 'true')
|
|
338
|
+
expect(root).toHaveAttribute('data-can-next', 'true')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('marks data-can-prev false at the start edge', () => {
|
|
342
|
+
const { container } = render(<Carousel items={items} hasGradient />)
|
|
343
|
+
const root = container.querySelector('.charcoal-carousel')
|
|
344
|
+
const scroller = getScroller()
|
|
345
|
+
mockScrollerGeometry(scroller, { scrollLeft: 0 })
|
|
346
|
+
act(() => {
|
|
347
|
+
fireEvent.scroll(scroller)
|
|
348
|
+
})
|
|
349
|
+
expect(root).toHaveAttribute('data-can-prev', 'false')
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('defaultScroll (initial position)', () => {
|
|
354
|
+
let lastSetLeft: number | undefined
|
|
355
|
+
let swSpy: ReturnType<typeof vi.spyOn>
|
|
356
|
+
let cwSpy: ReturnType<typeof vi.spyOn>
|
|
357
|
+
let slSpy: ReturnType<typeof vi.spyOn>
|
|
358
|
+
|
|
359
|
+
beforeEach(() => {
|
|
360
|
+
lastSetLeft = undefined
|
|
361
|
+
swSpy = vi
|
|
362
|
+
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
|
363
|
+
.mockReturnValue(2400)
|
|
364
|
+
cwSpy = vi
|
|
365
|
+
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
|
366
|
+
.mockReturnValue(800)
|
|
367
|
+
slSpy = vi
|
|
368
|
+
.spyOn(HTMLElement.prototype, 'scrollLeft', 'set')
|
|
369
|
+
.mockImplementation((v: number) => {
|
|
370
|
+
lastSetLeft = v
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
afterEach(() => {
|
|
375
|
+
swSpy.mockRestore()
|
|
376
|
+
cwSpy.mockRestore()
|
|
377
|
+
slSpy.mockRestore()
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('defaults to the left edge (0)', () => {
|
|
381
|
+
render(<Carousel items={items} />)
|
|
382
|
+
expect(lastSetLeft).toBe(0)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('centers the content when align=center (maxScroll / 2)', () => {
|
|
386
|
+
render(<Carousel items={items} defaultScroll={{ align: 'center' }} />)
|
|
387
|
+
expect(lastSetLeft).toBe(800)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('aligns to the right edge when align=right (maxScroll)', () => {
|
|
391
|
+
render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
|
|
392
|
+
expect(lastSetLeft).toBe(1600)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('applies offset from the base position', () => {
|
|
396
|
+
render(
|
|
397
|
+
<Carousel
|
|
398
|
+
items={items}
|
|
399
|
+
defaultScroll={{ align: 'left', offset: 100 }}
|
|
400
|
+
/>,
|
|
401
|
+
)
|
|
402
|
+
expect(lastSetLeft).toBe(100)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe('defaultScroll robustness (late-loading content)', () => {
|
|
407
|
+
let roCallbacks: Array<(entries: Array<{ target: Element }>) => void>
|
|
408
|
+
let scrollWidthVal: number
|
|
409
|
+
let lastSetLeft: number | undefined
|
|
410
|
+
let origRO: typeof globalThis.ResizeObserver
|
|
411
|
+
let spies: Array<ReturnType<typeof vi.spyOn>>
|
|
412
|
+
|
|
413
|
+
beforeEach(() => {
|
|
414
|
+
roCallbacks = []
|
|
415
|
+
scrollWidthVal = 800 // == clientWidth -> maxScroll 0 at mount
|
|
416
|
+
lastSetLeft = undefined
|
|
417
|
+
origRO = globalThis.ResizeObserver
|
|
418
|
+
globalThis.ResizeObserver = class {
|
|
419
|
+
observe = vi.fn()
|
|
420
|
+
unobserve = vi.fn()
|
|
421
|
+
disconnect = vi.fn()
|
|
422
|
+
constructor(cb: (entries: Array<{ target: Element }>) => void) {
|
|
423
|
+
roCallbacks.push(cb)
|
|
424
|
+
}
|
|
425
|
+
} as unknown as typeof globalThis.ResizeObserver
|
|
426
|
+
spies = [
|
|
427
|
+
vi
|
|
428
|
+
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
|
429
|
+
.mockImplementation(() => scrollWidthVal),
|
|
430
|
+
vi
|
|
431
|
+
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
|
432
|
+
.mockReturnValue(800),
|
|
433
|
+
vi
|
|
434
|
+
.spyOn(HTMLElement.prototype, 'scrollLeft', 'set')
|
|
435
|
+
.mockImplementation((v: number) => {
|
|
436
|
+
lastSetLeft = v
|
|
437
|
+
}),
|
|
438
|
+
]
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
afterEach(() => {
|
|
442
|
+
spies.forEach((s) => s.mockRestore())
|
|
443
|
+
globalThis.ResizeObserver = origRO
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('re-applies the initial position when content width settles later', () => {
|
|
447
|
+
render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
|
|
448
|
+
// at mount the content is not laid out yet (maxScroll 0) -> right -> 0
|
|
449
|
+
expect(lastSetLeft).toBe(0)
|
|
450
|
+
|
|
451
|
+
// images finish loading -> content width grows
|
|
452
|
+
scrollWidthVal = 2400
|
|
453
|
+
lastSetLeft = undefined
|
|
454
|
+
const entries = [...getScroller().children].map((el) => ({ target: el }))
|
|
455
|
+
act(() => {
|
|
456
|
+
roCallbacks.forEach((cb) => cb(entries))
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
expect(lastSetLeft).toBe(1600)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('stops re-applying once the user interacts', () => {
|
|
463
|
+
render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
|
|
464
|
+
|
|
465
|
+
// user interacts (pointerdown on the scroller) before the content settles
|
|
466
|
+
fireEvent.pointerDown(getScroller())
|
|
467
|
+
|
|
468
|
+
scrollWidthVal = 2400
|
|
469
|
+
lastSetLeft = undefined
|
|
470
|
+
const entries = [...getScroller().children].map((el) => ({ target: el }))
|
|
471
|
+
act(() => {
|
|
472
|
+
roCallbacks.forEach((cb) => cb(entries))
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
expect(lastSetLeft).toBeUndefined()
|
|
476
|
+
})
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
describe('props override defaults', () => {
|
|
480
|
+
it('shows indicator on Size M when prop is true', () => {
|
|
481
|
+
const { container } = render(
|
|
482
|
+
<Carousel items={items} size="M" indicator />,
|
|
483
|
+
)
|
|
484
|
+
expect(container.querySelector("[data-indicator='true']")).toBeTruthy()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('shows navigation on Size S when prop is true', () => {
|
|
488
|
+
render(<Carousel items={items} size="S" navigationButtons />)
|
|
489
|
+
const nav = document.querySelector('.charcoal-carousel__navigation')
|
|
490
|
+
expect(nav).toHaveAttribute('data-visible', 'true')
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
describe('variant data attributes', () => {
|
|
495
|
+
it('sets gradient data attribute', () => {
|
|
496
|
+
const { container } = render(<Carousel items={items} hasGradient />)
|
|
497
|
+
expect(container.querySelector("[data-has-gradient='true']")).toBeTruthy()
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('sets full-width data attribute', () => {
|
|
501
|
+
const { container } = render(<Carousel items={items} fullWidth />)
|
|
502
|
+
expect(container.querySelector("[data-full-width='true']")).toBeTruthy()
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
describe('scrollSnap', () => {
|
|
507
|
+
const getRegion = () => screen.getByRole('region')
|
|
508
|
+
|
|
509
|
+
it('defaults to none / center on Size M (sandbox 同等の 0.75)', () => {
|
|
510
|
+
render(<Carousel items={items} size="M" />)
|
|
511
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'none')
|
|
512
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-align', 'center')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('defaults to mandatory on Size S', () => {
|
|
516
|
+
render(<Carousel items={items} size="S" />)
|
|
517
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'mandatory')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('overrides type per prop (item-ごと mandatory)', () => {
|
|
521
|
+
render(
|
|
522
|
+
<Carousel items={items} size="M" scrollSnap={{ type: 'mandatory' }} />,
|
|
523
|
+
)
|
|
524
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'mandatory')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('overrides align per prop', () => {
|
|
528
|
+
render(<Carousel items={items} scrollSnap={{ align: 'start' }} />)
|
|
529
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-align', 'start')
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('supports disabling snap (none)', () => {
|
|
533
|
+
render(<Carousel items={items} scrollSnap={{ type: 'none' }} />)
|
|
534
|
+
expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'none')
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
describe('react-sandbox compat (callbacks / ref)', () => {
|
|
539
|
+
it('calls onScroll with scrollLeft on scroll', () => {
|
|
540
|
+
const onScroll = vi.fn()
|
|
541
|
+
render(<Carousel items={items} onScroll={onScroll} />)
|
|
542
|
+
const scroller = getScroller()
|
|
543
|
+
mockScrollerGeometry(scroller, { scrollLeft: 240 })
|
|
544
|
+
act(() => {
|
|
545
|
+
fireEvent.scroll(scroller)
|
|
546
|
+
})
|
|
547
|
+
expect(onScroll).toHaveBeenCalledWith(240)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('calls onScrollStateChange when scrollability changes', () => {
|
|
551
|
+
const onScrollStateChange = vi.fn()
|
|
552
|
+
render(
|
|
553
|
+
<Carousel items={items} onScrollStateChange={onScrollStateChange} />,
|
|
554
|
+
)
|
|
555
|
+
const scroller = getScroller()
|
|
556
|
+
mockScrollerGeometry(scroller, { scrollLeft: 100 })
|
|
557
|
+
act(() => {
|
|
558
|
+
fireEvent.scroll(scroller)
|
|
559
|
+
})
|
|
560
|
+
expect(onScrollStateChange).toHaveBeenCalledWith(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('exposes resetScroll() via ref to restore the initial position', () => {
|
|
564
|
+
let lastSetLeft: number | undefined
|
|
565
|
+
const spies = [
|
|
566
|
+
vi
|
|
567
|
+
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
|
568
|
+
.mockReturnValue(2400),
|
|
569
|
+
vi
|
|
570
|
+
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
|
571
|
+
.mockReturnValue(800),
|
|
572
|
+
vi
|
|
573
|
+
.spyOn(HTMLElement.prototype, 'scrollLeft', 'set')
|
|
574
|
+
.mockImplementation((v: number) => {
|
|
575
|
+
lastSetLeft = v
|
|
576
|
+
}),
|
|
577
|
+
]
|
|
578
|
+
const ref = createRef<CarouselHandlerRef>()
|
|
579
|
+
render(
|
|
580
|
+
<Carousel ref={ref} items={items} defaultScroll={{ align: 'right' }} />,
|
|
581
|
+
)
|
|
582
|
+
lastSetLeft = undefined
|
|
583
|
+
act(() => {
|
|
584
|
+
ref.current?.resetScroll()
|
|
585
|
+
})
|
|
586
|
+
// align right -> maxScroll = 2400 - 800 = 1600
|
|
587
|
+
expect(lastSetLeft).toBe(1600)
|
|
588
|
+
spies.forEach((s) => s.mockRestore())
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('calls onResize with clientWidth when the scroller resizes', () => {
|
|
592
|
+
const roCallbacks: Array<(e: Array<{ target: Element }>) => void> = []
|
|
593
|
+
const origRO = globalThis.ResizeObserver
|
|
594
|
+
globalThis.ResizeObserver = class {
|
|
595
|
+
observe = vi.fn()
|
|
596
|
+
unobserve = vi.fn()
|
|
597
|
+
disconnect = vi.fn()
|
|
598
|
+
constructor(cb: (e: Array<{ target: Element }>) => void) {
|
|
599
|
+
roCallbacks.push(cb)
|
|
600
|
+
}
|
|
601
|
+
} as unknown as typeof globalThis.ResizeObserver
|
|
602
|
+
const cwSpy = vi
|
|
603
|
+
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
|
604
|
+
.mockReturnValue(640)
|
|
605
|
+
const onResize = vi.fn()
|
|
606
|
+
render(<Carousel items={items} onResize={onResize} />)
|
|
607
|
+
act(() => {
|
|
608
|
+
roCallbacks.forEach((cb) => cb([]))
|
|
609
|
+
})
|
|
610
|
+
expect(onResize).toHaveBeenCalledWith(640)
|
|
611
|
+
cwSpy.mockRestore()
|
|
612
|
+
globalThis.ResizeObserver = origRO
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
describe('SSR (server rendering)', () => {
|
|
617
|
+
// jsdom では window が定義されているため renderToString 時に
|
|
618
|
+
// 「useLayoutEffect does nothing on the server」警告が出る(実 SSR=window 無しでは
|
|
619
|
+
// useEffect に切り替わり出ない)。テスト出力を汚さないよう、この警告だけ抑制する。
|
|
620
|
+
let errorSpy: ReturnType<typeof vi.spyOn>
|
|
621
|
+
beforeEach(() => {
|
|
622
|
+
// eslint-disable-next-line no-console
|
|
623
|
+
const original = console.error
|
|
624
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
625
|
+
if (
|
|
626
|
+
typeof args[0] === 'string' &&
|
|
627
|
+
args[0].includes('useLayoutEffect does nothing on the server')
|
|
628
|
+
) {
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
// eslint-disable-next-line no-console
|
|
632
|
+
original(...args)
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
afterEach(() => {
|
|
636
|
+
errorSpy.mockRestore()
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('renders to static HTML without throwing', () => {
|
|
640
|
+
// engine は effect 内でのみ生成され、useSyncExternalStore は
|
|
641
|
+
// getServerSnapshot を使うため、サーバー描画でブラウザ API に触れない。
|
|
642
|
+
expect(() =>
|
|
643
|
+
renderToString(
|
|
644
|
+
<Carousel items={items} defaultScroll={{ align: 'center' }} />,
|
|
645
|
+
),
|
|
646
|
+
).not.toThrow()
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('uses the server snapshot (canPrev/canNext = false) and renders items + indicator', () => {
|
|
650
|
+
const html = renderToString(<Carousel items={items} size="M" />)
|
|
651
|
+
|
|
652
|
+
// サーバースナップショットでは canPrev/canNext は false。
|
|
653
|
+
expect(html).toContain('data-can-prev="false"')
|
|
654
|
+
expect(html).toContain('data-can-next="false"')
|
|
655
|
+
// 全アイテムが描画される。
|
|
656
|
+
for (let i = 0; i < 6; i++) {
|
|
657
|
+
expect(html).toContain(`data-testid="slide-${i}"`)
|
|
658
|
+
}
|
|
659
|
+
// インジケーターは常に描画される(表示制御は CSS @supports が担当)。
|
|
660
|
+
expect(html).toContain('charcoal-carousel__indicator')
|
|
661
|
+
// ナビゲーションボタンは存在する(サーバースナップショットでは無効状態)。
|
|
662
|
+
expect(html).toContain('aria-label="Previous"')
|
|
663
|
+
expect(html).toContain('aria-label="Next"')
|
|
664
|
+
})
|
|
665
|
+
})
|
|
666
|
+
})
|