@charcoal-ui/react 6.0.0-rc.4 → 6.0.0-rc.6

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 (38) hide show
  1. package/dist/_lib/useIsomorphicLayoutEffect.d.ts +6 -0
  2. package/dist/_lib/useIsomorphicLayoutEffect.d.ts.map +1 -0
  3. package/dist/components/Carousel/index.d.ts +2 -6
  4. package/dist/components/Carousel/index.d.ts.map +1 -1
  5. package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -1
  6. package/dist/components/Modal/index.d.ts.map +1 -1
  7. package/dist/components/Modal/useTransitionPresence.d.ts +19 -0
  8. package/dist/components/Modal/useTransitionPresence.d.ts.map +1 -0
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.css +124 -49
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/layered.css +124 -49
  17. package/dist/layered.css.map +1 -1
  18. package/package.json +5 -6
  19. package/src/__tests__/token-v1-compat.test.ts +0 -1
  20. package/src/_lib/useIsomorphicLayoutEffect.ts +7 -0
  21. package/src/components/Carousel/MIGRATION.md +39 -39
  22. package/src/components/Carousel/__snapshots__/index.css.snap +63 -39
  23. package/src/components/Carousel/index.browser.test.tsx +102 -0
  24. package/src/components/Carousel/index.css +68 -46
  25. package/src/components/Carousel/index.story.tsx +87 -68
  26. package/src/components/Carousel/index.test.tsx +120 -47
  27. package/src/components/Carousel/index.tsx +30 -14
  28. package/src/components/Carousel/useCarouselScroller.ts +8 -12
  29. package/src/components/FieldLabel/__snapshots__/index.css.snap +1 -1
  30. package/src/components/FieldLabel/index.css +1 -1
  31. package/src/components/IconButton/__snapshots__/index.css.snap +1 -1
  32. package/src/components/IconButton/index.css +1 -1
  33. package/src/components/Modal/__snapshots__/index.css.snap +32 -1
  34. package/src/components/Modal/index.css +34 -0
  35. package/src/components/Modal/index.test.tsx +200 -0
  36. package/src/components/Modal/index.tsx +48 -78
  37. package/src/components/Modal/useTransitionPresence.ts +95 -0
  38. package/src/index.ts +0 -1
@@ -2,12 +2,13 @@ import { createRef } from 'react'
2
2
  import { render, screen, fireEvent, act } from '@testing-library/react'
3
3
  import { renderToString } from 'react-dom/server'
4
4
  import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
5
- import Carousel, { type CarouselItem, type CarouselHandlerRef } from '.'
5
+ import Carousel, { type CarouselHandlerRef } from '.'
6
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
- }))
7
+ const slides = Array.from({ length: 6 }, (_, i) => (
8
+ <div key={`item-${i}`} data-testid={`slide-${i}`}>
9
+ Slide {i}
10
+ </div>
11
+ ))
11
12
 
12
13
  function mockScrollerGeometry(
13
14
  el: HTMLElement,
@@ -77,14 +78,14 @@ function fireIntersect(el: Element) {
77
78
  describe('Carousel', () => {
78
79
  describe('rendering', () => {
79
80
  it('renders all items', () => {
80
- render(<Carousel items={items} />)
81
+ render(<Carousel>{slides}</Carousel>)
81
82
  for (let i = 0; i < 6; i++) {
82
83
  expect(screen.getByTestId(`slide-${i}`)).toBeInTheDocument()
83
84
  }
84
85
  })
85
86
 
86
87
  it('has carousel ARIA attributes', () => {
87
- render(<Carousel items={items} />)
88
+ render(<Carousel>{slides}</Carousel>)
88
89
  const region = screen.getByRole('region')
89
90
  expect(region).toHaveAttribute('aria-roledescription', 'carousel')
90
91
  expect(region).toHaveAttribute('aria-label', 'Carousel')
@@ -93,7 +94,7 @@ describe('Carousel', () => {
93
94
 
94
95
  describe('Size M (default)', () => {
95
96
  it('shows navigation buttons', () => {
96
- render(<Carousel items={items} size="M" />)
97
+ render(<Carousel size="M">{slides}</Carousel>)
97
98
  expect(
98
99
  screen.getByRole('button', { name: 'Previous' }),
99
100
  ).toBeInTheDocument()
@@ -101,7 +102,7 @@ describe('Carousel', () => {
101
102
  })
102
103
 
103
104
  it('hides indicator by default', () => {
104
- const { container } = render(<Carousel items={items} size="M" />)
105
+ const { container } = render(<Carousel size="M">{slides}</Carousel>)
105
106
  const indicator = container.querySelector('.charcoal-carousel__indicator')
106
107
  expect(indicator).toHaveAttribute('data-visible', 'false')
107
108
  })
@@ -109,7 +110,7 @@ describe('Carousel', () => {
109
110
 
110
111
  describe('Size S', () => {
111
112
  it('hides navigation buttons by default', () => {
112
- const { container } = render(<Carousel items={items} size="S" />)
113
+ const { container } = render(<Carousel size="S">{slides}</Carousel>)
113
114
  const nav = container.querySelector('.charcoal-carousel__navigation')
114
115
  expect(nav).toHaveAttribute('data-visible', 'false')
115
116
  })
@@ -117,13 +118,13 @@ describe('Carousel', () => {
117
118
 
118
119
  describe('navigation button state', () => {
119
120
  it('disables prev button at scroll start', () => {
120
- render(<Carousel items={items} />)
121
+ render(<Carousel>{slides}</Carousel>)
121
122
  const prev = screen.getByRole('button', { name: 'Previous' })
122
123
  expect(prev).toBeDisabled()
123
124
  })
124
125
 
125
126
  it('enables next button when scrollable content exists', () => {
126
- render(<Carousel items={items} />)
127
+ render(<Carousel>{slides}</Carousel>)
127
128
  const scroller = getScroller()
128
129
  mockScrollerGeometry(scroller, { scrollLeft: 0 })
129
130
  act(() => {
@@ -136,7 +137,7 @@ describe('Carousel', () => {
136
137
 
137
138
  describe('scrollByStep (0.75x viewport)', () => {
138
139
  it('calls scrollBy with 0.75x viewport width on next button click', () => {
139
- render(<Carousel items={items} />)
140
+ render(<Carousel>{slides}</Carousel>)
140
141
  const scroller = getScroller()
141
142
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
142
143
  act(() => {
@@ -156,7 +157,7 @@ describe('Carousel', () => {
156
157
  })
157
158
 
158
159
  it('calls scrollBy with negative 0.75x on prev button click', () => {
159
- render(<Carousel items={items} />)
160
+ render(<Carousel>{slides}</Carousel>)
160
161
  const scroller = getScroller()
161
162
  mockScrollerGeometry(scroller, { scrollLeft: 400 })
162
163
  act(() => {
@@ -176,7 +177,7 @@ describe('Carousel', () => {
176
177
  })
177
178
 
178
179
  it('respects custom scrollStep ratio (number)', () => {
179
- render(<Carousel items={items} scrollStep={0.5} />)
180
+ render(<Carousel scrollStep={0.5}>{slides}</Carousel>)
180
181
  const scroller = getScroller()
181
182
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
182
183
  act(() => {
@@ -199,7 +200,7 @@ describe('Carousel', () => {
199
200
  const scrollStep = vi.fn(
200
201
  ({ clientWidth }: { clientWidth: number }) => clientWidth - 48,
201
202
  )
202
- render(<Carousel items={items} scrollStep={scrollStep} />)
203
+ render(<Carousel scrollStep={scrollStep}>{slides}</Carousel>)
203
204
  const scroller = getScroller()
204
205
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
205
206
  act(() => {
@@ -228,7 +229,7 @@ describe('Carousel', () => {
228
229
 
229
230
  describe('keyboard navigation', () => {
230
231
  it('scrolls next by 0.75x viewport on ArrowRight', () => {
231
- render(<Carousel items={items} />)
232
+ render(<Carousel>{slides}</Carousel>)
232
233
  const scroller = getScroller()
233
234
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
234
235
  act(() => {
@@ -247,7 +248,7 @@ describe('Carousel', () => {
247
248
  })
248
249
 
249
250
  it('scrolls prev by negative 0.75x viewport on ArrowLeft', () => {
250
- render(<Carousel items={items} />)
251
+ render(<Carousel>{slides}</Carousel>)
251
252
  const scroller = getScroller()
252
253
  mockScrollerGeometry(scroller, { scrollLeft: 400 })
253
254
  act(() => {
@@ -266,7 +267,7 @@ describe('Carousel', () => {
266
267
  })
267
268
 
268
269
  it('ignores unrelated keys', () => {
269
- render(<Carousel items={items} />)
270
+ render(<Carousel>{slides}</Carousel>)
270
271
  const scroller = getScroller()
271
272
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
272
273
 
@@ -284,7 +285,7 @@ describe('Carousel', () => {
284
285
  // jsdom には scrollIntoView が無いのでモックを定義する。
285
286
  const scrollIntoView = vi.fn()
286
287
  Element.prototype.scrollIntoView = scrollIntoView
287
- const { container } = render(<Carousel items={items} size="S" />)
288
+ const { container } = render(<Carousel size="S">{slides}</Carousel>)
288
289
 
289
290
  const dots = container.querySelectorAll(
290
291
  '.charcoal-carousel__indicator__item',
@@ -311,7 +312,9 @@ describe('Carousel', () => {
311
312
  it('item が中央に入ると activeIndex が更新され indicator に反映される', () => {
312
313
  const restore = installIOMock()
313
314
  const { container } = render(
314
- <Carousel items={items} size="M" indicator />,
315
+ <Carousel size="M" indicator>
316
+ {slides}
317
+ </Carousel>,
315
318
  )
316
319
  const itemEls = container.querySelectorAll('.charcoal-carousel__item')
317
320
  act(() => {
@@ -327,7 +330,7 @@ describe('Carousel', () => {
327
330
 
328
331
  describe('scroll-state data attributes (mask)', () => {
329
332
  it('reflects canPrev/canNext on the root element', () => {
330
- const { container } = render(<Carousel items={items} hasGradient />)
333
+ const { container } = render(<Carousel hasGradient>{slides}</Carousel>)
331
334
  const root = container.querySelector('.charcoal-carousel')
332
335
  const scroller = getScroller()
333
336
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
@@ -339,7 +342,7 @@ describe('Carousel', () => {
339
342
  })
340
343
 
341
344
  it('marks data-can-prev false at the start edge', () => {
342
- const { container } = render(<Carousel items={items} hasGradient />)
345
+ const { container } = render(<Carousel hasGradient>{slides}</Carousel>)
343
346
  const root = container.querySelector('.charcoal-carousel')
344
347
  const scroller = getScroller()
345
348
  mockScrollerGeometry(scroller, { scrollLeft: 0 })
@@ -378,26 +381,25 @@ describe('Carousel', () => {
378
381
  })
379
382
 
380
383
  it('defaults to the left edge (0)', () => {
381
- render(<Carousel items={items} />)
384
+ render(<Carousel>{slides}</Carousel>)
382
385
  expect(lastSetLeft).toBe(0)
383
386
  })
384
387
 
385
388
  it('centers the content when align=center (maxScroll / 2)', () => {
386
- render(<Carousel items={items} defaultScroll={{ align: 'center' }} />)
389
+ render(<Carousel defaultScroll={{ align: 'center' }}>{slides}</Carousel>)
387
390
  expect(lastSetLeft).toBe(800)
388
391
  })
389
392
 
390
393
  it('aligns to the right edge when align=right (maxScroll)', () => {
391
- render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
394
+ render(<Carousel defaultScroll={{ align: 'right' }}>{slides}</Carousel>)
392
395
  expect(lastSetLeft).toBe(1600)
393
396
  })
394
397
 
395
398
  it('applies offset from the base position', () => {
396
399
  render(
397
- <Carousel
398
- items={items}
399
- defaultScroll={{ align: 'left', offset: 100 }}
400
- />,
400
+ <Carousel defaultScroll={{ align: 'left', offset: 100 }}>
401
+ {slides}
402
+ </Carousel>,
401
403
  )
402
404
  expect(lastSetLeft).toBe(100)
403
405
  })
@@ -444,7 +446,7 @@ describe('Carousel', () => {
444
446
  })
445
447
 
446
448
  it('re-applies the initial position when content width settles later', () => {
447
- render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
449
+ render(<Carousel defaultScroll={{ align: 'right' }}>{slides}</Carousel>)
448
450
  // at mount the content is not laid out yet (maxScroll 0) -> right -> 0
449
451
  expect(lastSetLeft).toBe(0)
450
452
 
@@ -460,7 +462,7 @@ describe('Carousel', () => {
460
462
  })
461
463
 
462
464
  it('stops re-applying once the user interacts', () => {
463
- render(<Carousel items={items} defaultScroll={{ align: 'right' }} />)
465
+ render(<Carousel defaultScroll={{ align: 'right' }}>{slides}</Carousel>)
464
466
 
465
467
  // user interacts (pointerdown on the scroller) before the content settles
466
468
  fireEvent.pointerDown(getScroller())
@@ -476,16 +478,31 @@ describe('Carousel', () => {
476
478
  })
477
479
  })
478
480
 
481
+ describe('children (sandbox-compatible API)', () => {
482
+ it('renders each direct child as one slide', () => {
483
+ const { container } = render(<Carousel>{slides}</Carousel>)
484
+ expect(
485
+ container.querySelectorAll('.charcoal-carousel__item'),
486
+ ).toHaveLength(6)
487
+ })
488
+ })
489
+
479
490
  describe('props override defaults', () => {
480
491
  it('shows indicator on Size M when prop is true', () => {
481
492
  const { container } = render(
482
- <Carousel items={items} size="M" indicator />,
493
+ <Carousel size="M" indicator>
494
+ {slides}
495
+ </Carousel>,
483
496
  )
484
497
  expect(container.querySelector("[data-indicator='true']")).toBeTruthy()
485
498
  })
486
499
 
487
500
  it('shows navigation on Size S when prop is true', () => {
488
- render(<Carousel items={items} size="S" navigationButtons />)
501
+ render(
502
+ <Carousel size="S" navigationButtons>
503
+ {slides}
504
+ </Carousel>,
505
+ )
489
506
  const nav = document.querySelector('.charcoal-carousel__navigation')
490
507
  expect(nav).toHaveAttribute('data-visible', 'true')
491
508
  })
@@ -493,12 +510,12 @@ describe('Carousel', () => {
493
510
 
494
511
  describe('variant data attributes', () => {
495
512
  it('sets gradient data attribute', () => {
496
- const { container } = render(<Carousel items={items} hasGradient />)
513
+ const { container } = render(<Carousel hasGradient>{slides}</Carousel>)
497
514
  expect(container.querySelector("[data-has-gradient='true']")).toBeTruthy()
498
515
  })
499
516
 
500
517
  it('sets full-width data attribute', () => {
501
- const { container } = render(<Carousel items={items} fullWidth />)
518
+ const { container } = render(<Carousel fullWidth>{slides}</Carousel>)
502
519
  expect(container.querySelector("[data-full-width='true']")).toBeTruthy()
503
520
  })
504
521
  })
@@ -507,30 +524,32 @@ describe('Carousel', () => {
507
524
  const getRegion = () => screen.getByRole('region')
508
525
 
509
526
  it('defaults to none / center on Size M (sandbox 同等の 0.75)', () => {
510
- render(<Carousel items={items} size="M" />)
527
+ render(<Carousel size="M">{slides}</Carousel>)
511
528
  expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'none')
512
529
  expect(getRegion()).toHaveAttribute('data-scroll-snap-align', 'center')
513
530
  })
514
531
 
515
532
  it('defaults to mandatory on Size S', () => {
516
- render(<Carousel items={items} size="S" />)
533
+ render(<Carousel size="S">{slides}</Carousel>)
517
534
  expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'mandatory')
518
535
  })
519
536
 
520
537
  it('overrides type per prop (item-ごと mandatory)', () => {
521
538
  render(
522
- <Carousel items={items} size="M" scrollSnap={{ type: 'mandatory' }} />,
539
+ <Carousel size="M" scrollSnap={{ type: 'mandatory' }}>
540
+ {slides}
541
+ </Carousel>,
523
542
  )
524
543
  expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'mandatory')
525
544
  })
526
545
 
527
546
  it('overrides align per prop', () => {
528
- render(<Carousel items={items} scrollSnap={{ align: 'start' }} />)
547
+ render(<Carousel scrollSnap={{ align: 'start' }}>{slides}</Carousel>)
529
548
  expect(getRegion()).toHaveAttribute('data-scroll-snap-align', 'start')
530
549
  })
531
550
 
532
551
  it('supports disabling snap (none)', () => {
533
- render(<Carousel items={items} scrollSnap={{ type: 'none' }} />)
552
+ render(<Carousel scrollSnap={{ type: 'none' }}>{slides}</Carousel>)
534
553
  expect(getRegion()).toHaveAttribute('data-scroll-snap-type', 'none')
535
554
  })
536
555
  })
@@ -538,7 +557,7 @@ describe('Carousel', () => {
538
557
  describe('react-sandbox compat (callbacks / ref)', () => {
539
558
  it('calls onScroll with scrollLeft on scroll', () => {
540
559
  const onScroll = vi.fn()
541
- render(<Carousel items={items} onScroll={onScroll} />)
560
+ render(<Carousel onScroll={onScroll}>{slides}</Carousel>)
542
561
  const scroller = getScroller()
543
562
  mockScrollerGeometry(scroller, { scrollLeft: 240 })
544
563
  act(() => {
@@ -550,7 +569,7 @@ describe('Carousel', () => {
550
569
  it('calls onScrollStateChange when scrollability changes', () => {
551
570
  const onScrollStateChange = vi.fn()
552
571
  render(
553
- <Carousel items={items} onScrollStateChange={onScrollStateChange} />,
572
+ <Carousel onScrollStateChange={onScrollStateChange}>{slides}</Carousel>,
554
573
  )
555
574
  const scroller = getScroller()
556
575
  mockScrollerGeometry(scroller, { scrollLeft: 100 })
@@ -577,7 +596,9 @@ describe('Carousel', () => {
577
596
  ]
578
597
  const ref = createRef<CarouselHandlerRef>()
579
598
  render(
580
- <Carousel ref={ref} items={items} defaultScroll={{ align: 'right' }} />,
599
+ <Carousel ref={ref} defaultScroll={{ align: 'right' }}>
600
+ {slides}
601
+ </Carousel>,
581
602
  )
582
603
  lastSetLeft = undefined
583
604
  act(() => {
@@ -603,7 +624,7 @@ describe('Carousel', () => {
603
624
  .spyOn(HTMLElement.prototype, 'clientWidth', 'get')
604
625
  .mockReturnValue(640)
605
626
  const onResize = vi.fn()
606
- render(<Carousel items={items} onResize={onResize} />)
627
+ render(<Carousel onResize={onResize}>{slides}</Carousel>)
607
628
  act(() => {
608
629
  roCallbacks.forEach((cb) => cb([]))
609
630
  })
@@ -613,6 +634,58 @@ describe('Carousel', () => {
613
634
  })
614
635
  })
615
636
 
637
+ describe('navigation button visibility (focus modality)', () => {
638
+ // jsdom はジオメトリが全て 0 で Next ボタンが disabled(=フォーカス不可)の
639
+ // ままになるため、スクロール余地をモックして有効化してからフォーカスする。
640
+ function renderWithFocusableNext() {
641
+ render(<Carousel size="M">{slides}</Carousel>)
642
+ const scroller = getScroller()
643
+ mockScrollerGeometry(scroller, { scrollLeft: 0 })
644
+ scroller.scrollBy = vi.fn()
645
+ act(() => {
646
+ fireEvent.scroll(scroller)
647
+ })
648
+ const next = screen.getByRole('button', { name: 'Next' })
649
+ expect(next).not.toBeDisabled()
650
+ return next
651
+ }
652
+
653
+ // react-aria は document 上の keydown / pointerdown でフォーカスの由来
654
+ // (keyboard / pointer)を判定するため、focus() の前にイベントを発火して
655
+ // モダリティを切り替える。
656
+ it('does not mark focus-visible when a button is focused by pointer', () => {
657
+ const next = renderWithFocusableNext()
658
+ fireEvent.pointerDown(next)
659
+ fireEvent.mouseDown(next)
660
+ act(() => next.focus())
661
+ fireEvent.click(next)
662
+ expect(document.activeElement).toBe(next)
663
+ expect(screen.getByRole('region')).not.toHaveAttribute(
664
+ 'data-focus-visible-within',
665
+ )
666
+ })
667
+
668
+ it('marks focus-visible when a button is focused by keyboard', () => {
669
+ const next = renderWithFocusableNext()
670
+ fireEvent.keyDown(document.body, { key: 'Tab' })
671
+ act(() => next.focus())
672
+ expect(screen.getByRole('region')).toHaveAttribute(
673
+ 'data-focus-visible-within',
674
+ 'true',
675
+ )
676
+ })
677
+
678
+ it('clears focus-visible when focus leaves the carousel', () => {
679
+ const next = renderWithFocusableNext()
680
+ fireEvent.keyDown(document.body, { key: 'Tab' })
681
+ act(() => next.focus())
682
+ act(() => next.blur())
683
+ expect(screen.getByRole('region')).not.toHaveAttribute(
684
+ 'data-focus-visible-within',
685
+ )
686
+ })
687
+ })
688
+
616
689
  describe('SSR (server rendering)', () => {
617
690
  // jsdom では window が定義されているため renderToString 時に
618
691
  // 「useLayoutEffect does nothing on the server」警告が出る(実 SSR=window 無しでは
@@ -641,13 +714,13 @@ describe('Carousel', () => {
641
714
  // getServerSnapshot を使うため、サーバー描画でブラウザ API に触れない。
642
715
  expect(() =>
643
716
  renderToString(
644
- <Carousel items={items} defaultScroll={{ align: 'center' }} />,
717
+ <Carousel defaultScroll={{ align: 'center' }}>{slides}</Carousel>,
645
718
  ),
646
719
  ).not.toThrow()
647
720
  })
648
721
 
649
722
  it('uses the server snapshot (canPrev/canNext = false) and renders items + indicator', () => {
650
- const html = renderToString(<Carousel items={items} size="M" />)
723
+ const html = renderToString(<Carousel size="M">{slides}</Carousel>)
651
724
 
652
725
  // サーバースナップショットでは canPrev/canNext は false。
653
726
  expect(html).toContain('data-can-prev="false"')
@@ -1,7 +1,9 @@
1
1
  import './index.css'
2
2
 
3
3
  import {
4
+ Children,
4
5
  forwardRef,
6
+ isValidElement,
5
7
  memo,
6
8
  useCallback,
7
9
  useImperativeHandle,
@@ -23,11 +25,6 @@ import { useCarouselScroller } from './useCarouselScroller'
23
25
 
24
26
  const getServerSnapshot = (): CarouselState => INITIAL_CAROUSEL_STATE
25
27
 
26
- export type CarouselItem = {
27
- id: string
28
- children: ReactNode
29
- }
30
-
31
28
  export type ScrollAlign = 'left' | 'center' | 'right'
32
29
 
33
30
  export type ScrollSnapType = 'none' | 'proximity' | 'mandatory'
@@ -70,7 +67,8 @@ export type CarouselProps = Readonly<{
70
67
  onResize?: (width: number) => void
71
68
  onScrollStateChange?: (canScroll: boolean) => void
72
69
  defaultScroll?: { align?: ScrollAlign; offset?: number }
73
- items: CarouselItem[]
70
+ // 1 直接子要素 = 1 スライド(react-sandbox 互換)。
71
+ children: ReactNode
74
72
  }>
75
73
 
76
74
  type Direction = 'prev' | 'next'
@@ -149,6 +147,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
149
147
  onResize,
150
148
  onScrollStateChange,
151
149
  defaultScroll: { align = 'left', offset = 0 } = {},
150
+ children,
152
151
  ...props
153
152
  }: CarouselProps,
154
153
  ref,
@@ -159,13 +158,20 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
159
158
  const snapType = scrollSnap?.type ?? (size === 'S' ? 'mandatory' : 'none')
160
159
  const snapAlign = scrollSnap?.align ?? 'center'
161
160
 
161
+ // 直接子要素 1 つを 1 スライドとして数える。key は子要素の key を引き継ぐ
162
+ // (toArray が付与する接頭辞付き key。無ければ index)。
163
+ const slides = Children.toArray(children)
164
+ const slideKeys = slides.map((slide, i) =>
165
+ isValidElement(slide) && slide.key != null ? slide.key : i,
166
+ )
167
+
162
168
  const scrollerRef = useRef<HTMLDivElement>(null)
163
169
  const [store] = useState(createCarouselStore)
164
170
 
165
171
  const { scrollByStep, onItemResize, resetScroll } = useCarouselScroller(
166
172
  scrollerRef,
167
173
  store,
168
- props.items.length,
174
+ slides.length,
169
175
  { align, offset, scrollStep, onScroll, onResize, onScrollStateChange },
170
176
  )
171
177
 
@@ -202,8 +208,14 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
202
208
  isFocusVisible: scrollerFocusVisible,
203
209
  } = useFocusRing()
204
210
 
211
+ // ナビゲーションボタン表示用。クリックで残留したフォーカス(pointer 由来)では
212
+ // 表示し続けないよう、キーボード由来のフォーカスのみを検知する。
213
+ const { focusProps: rootFocusProps, isFocusVisible: rootFocusVisible } =
214
+ useFocusRing({ within: true })
215
+
205
216
  return (
206
217
  <div
218
+ {...rootFocusProps}
207
219
  className={className}
208
220
  data-size={size}
209
221
  data-has-gradient={hasGradient}
@@ -213,26 +225,30 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
213
225
  data-scroll-snap-align={snapAlign}
214
226
  data-can-prev={canPrev}
215
227
  data-can-next={canNext}
228
+ data-focus-visible-within={rootFocusVisible || undefined}
216
229
  role="region"
217
230
  aria-roledescription="carousel"
218
231
  aria-label="Carousel"
219
232
  >
220
- <div className="charcoal-carousel__viewport">
233
+ {/* フォーカスリングは viewport に描く(理由は index.css の同セレクタ参照) */}
234
+ <div
235
+ className="charcoal-carousel__viewport"
236
+ data-focus-visible={scrollerFocusVisible || undefined}
237
+ >
221
238
  <div
222
239
  {...mergeProps(scrollerFocusProps, keyboardProps)}
223
240
  ref={scrollerRef}
224
241
  className="charcoal-carousel__scroller"
225
242
  tabIndex={0}
226
- data-focus-visible={scrollerFocusVisible || undefined}
227
243
  >
228
- {props.items.map((item, i) => (
244
+ {slides.map((slide, i) => (
229
245
  <CarouselSlide
230
- key={item.id}
246
+ key={slideKeys[i]}
231
247
  index={i}
232
248
  store={store}
233
249
  onResize={onItemResize}
234
250
  >
235
- {item.children}
251
+ {slide}
236
252
  </CarouselSlide>
237
253
  ))}
238
254
  </div>
@@ -260,9 +276,9 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
260
276
  data-visible={showIndicator}
261
277
  aria-hidden={!showIndicator}
262
278
  >
263
- {props.items.map((item, i) => (
279
+ {slides.map((_, i) => (
264
280
  <CarouselIndicatorItem
265
- key={item.id}
281
+ key={slideKeys[i]}
266
282
  index={i}
267
283
  isActive={i === activeIndex}
268
284
  onSelect={scrollToItem}
@@ -1,16 +1,8 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useLayoutEffect,
5
- useRef,
6
- type RefObject,
7
- } from 'react'
1
+ import { useCallback, useEffect, useRef, type RefObject } from 'react'
2
+ import { useIsomorphicLayoutEffect } from '../../_lib/useIsomorphicLayoutEffect'
8
3
  import type { CarouselStore } from './carouselStore'
9
4
  import type { ScrollAlign, ScrollStep } from './index'
10
5
 
11
- const useIsomorphicLayoutEffect =
12
- typeof window !== 'undefined' ? useLayoutEffect : useEffect
13
-
14
6
  const INTERACTION_EVENTS = ['pointerdown', 'wheel', 'touchstart'] as const
15
7
 
16
8
  export type CarouselScrollerOptions = Readonly<{
@@ -74,8 +66,12 @@ export function useCarouselScroller(
74
66
  left = maxScroll + offset
75
67
  break
76
68
  }
77
- // eslint-disable-next-line react-compiler/react-compiler
78
- el.scrollLeft = Math.max(0, Math.min(left, maxScroll))
69
+ // scrollLeft 代入は CSS の scroll-behavior: smooth の対象になり
70
+ // 初期位置決めがアニメーションしてしまうため、instant で確定させる。
71
+ el.scrollTo({
72
+ left: Math.max(0, Math.min(left, maxScroll)),
73
+ behavior: 'instant',
74
+ })
79
75
  }, [scrollerRef, align, offset])
80
76
 
81
77
  // canPrev/canNext: scroll で更新。onScroll もここから発火。itemCount 変化で貼り直し。
@@ -3,7 +3,7 @@
3
3
  line-height: 22px;
4
4
  font-weight: bold;
5
5
  display: flow-root;
6
- color: var(--charcoal-color-text-default);
6
+ color: var(--charcoal-color-text-default-text1);
7
7
  }
8
8
 
9
9
  .charcoal-field-label-required-text {
@@ -3,7 +3,7 @@
3
3
  line-height: 22px;
4
4
  font-weight: bold;
5
5
  display: flow-root;
6
- color: var(--charcoal-color-text-default);
6
+ color: var(--charcoal-color-text-default-text1);
7
7
  }
8
8
 
9
9
  .charcoal-field-label-required-text {
@@ -54,7 +54,7 @@
54
54
 
55
55
  .charcoal-icon-button[data-variant='Default'] {
56
56
  color: var(--charcoal-color-icon-tertiary-default);
57
- background-color: var(--charcoal-color-container-default-a);
57
+ background-color: transparent;
58
58
  }
59
59
 
60
60
  .charcoal-icon-button[data-variant='Default'][data-active='true']:not(:disabled):not([aria-disabled]), .charcoal-icon-button[data-variant='Default'][data-active='true'][aria-disabled='false'] {
@@ -56,7 +56,7 @@
56
56
 
57
57
  &[data-variant='Default'] {
58
58
  color: var(--charcoal-color-icon-tertiary-default);
59
- background-color: var(--charcoal-color-container-default-a);
59
+ background-color: transparent;
60
60
 
61
61
  &[data-active='true']:not(:disabled):not([aria-disabled]),
62
62
  &[data-active='true'][aria-disabled='false'] {
@@ -19,6 +19,38 @@
19
19
  }
20
20
  }
21
21
 
22
+ /* easeOutQuart。duration は useTransitionPresence の MODAL_TRANSITION_DURATION_MS と一致させること。
23
+ transition は entering / exiting に限定し、開いたままテーマ切り替え等で
24
+ 色や transform が変わってもアニメーションしないようにする */
25
+
26
+ .charcoal-modal-background[data-animation] {
27
+ overflow: hidden;
28
+ }
29
+
30
+ .charcoal-modal-background[data-animation='entering'], .charcoal-modal-background[data-animation='exiting'] {
31
+ transition: background-color 400ms cubic-bezier(0.25, 1, 0.5, 1);
32
+ }
33
+
34
+ /* 閉じアニメーション中はクリックを下のページへ通す(transitionend が来ない環境で
35
+ 透明なオーバーレイがタップを奪い続けるのも防ぐ) */
36
+
37
+ .charcoal-modal-background[data-animation='exited'], .charcoal-modal-background[data-animation='exiting'] {
38
+ background-color: transparent;
39
+ pointer-events: none;
40
+ }
41
+
42
+ .charcoal-modal-background[data-animation='entered'] {
43
+ overflow: auto;
44
+ }
45
+
46
+ .charcoal-modal-background[data-animation='entering'] > .charcoal-modal-dialog, .charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
47
+ transition: transform 400ms cubic-bezier(0.25, 1, 0.5, 1);
48
+ }
49
+
50
+ .charcoal-modal-background[data-animation='exited'] > .charcoal-modal-dialog, .charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
51
+ transform: translateY(100%);
52
+ }
53
+
22
54
  .charcoal-modal-close-button {
23
55
  position: absolute;
24
56
  top: 8px;
@@ -32,4 +64,3 @@
32
64
  font-weight: inherit;
33
65
  font-size: inherit;
34
66
  }
35
-