@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.
Files changed (42) hide show
  1. package/dist/components/Carousel/CarouselItem.d.ts +10 -0
  2. package/dist/components/Carousel/CarouselItem.d.ts.map +1 -0
  3. package/dist/components/Carousel/carouselStore.d.ts +25 -0
  4. package/dist/components/Carousel/carouselStore.d.ts.map +1 -0
  5. package/dist/components/Carousel/index.d.ts +61 -0
  6. package/dist/components/Carousel/index.d.ts.map +1 -0
  7. package/dist/components/Carousel/intersectionObserver.d.ts +7 -0
  8. package/dist/components/Carousel/intersectionObserver.d.ts.map +1 -0
  9. package/dist/components/Carousel/resizeObserver.d.ts +6 -0
  10. package/dist/components/Carousel/resizeObserver.d.ts.map +1 -0
  11. package/dist/components/Carousel/store.d.ts +7 -0
  12. package/dist/components/Carousel/store.d.ts.map +1 -0
  13. package/dist/components/Carousel/useCarouselScroller.d.ts +18 -0
  14. package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -0
  15. package/dist/index.cjs +2 -2
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.css +275 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/layered.css +275 -0
  23. package/dist/layered.css.map +1 -1
  24. package/package.json +5 -5
  25. package/src/components/Carousel/CarouselItem.tsx +56 -0
  26. package/src/components/Carousel/MIGRATION.md +117 -0
  27. package/src/components/Carousel/Migration.mdx +8 -0
  28. package/src/components/Carousel/__snapshots__/index.css.snap +274 -0
  29. package/src/components/Carousel/carouselStore.test.ts +38 -0
  30. package/src/components/Carousel/carouselStore.ts +48 -0
  31. package/src/components/Carousel/index.css +274 -0
  32. package/src/components/Carousel/index.story.tsx +169 -0
  33. package/src/components/Carousel/index.test.tsx +666 -0
  34. package/src/components/Carousel/index.tsx +278 -0
  35. package/src/components/Carousel/intersectionObserver.test.ts +68 -0
  36. package/src/components/Carousel/intersectionObserver.ts +58 -0
  37. package/src/components/Carousel/resizeObserver.test.ts +60 -0
  38. package/src/components/Carousel/resizeObserver.ts +35 -0
  39. package/src/components/Carousel/store.test.ts +25 -0
  40. package/src/components/Carousel/store.ts +27 -0
  41. package/src/components/Carousel/useCarouselScroller.ts +158 -0
  42. 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
+ })