@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.
- package/dist/_lib/useIsomorphicLayoutEffect.d.ts +6 -0
- package/dist/_lib/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/components/Carousel/index.d.ts +2 -6
- package/dist/components/Carousel/index.d.ts.map +1 -1
- package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -1
- package/dist/components/Modal/index.d.ts.map +1 -1
- package/dist/components/Modal/useTransitionPresence.d.ts +19 -0
- package/dist/components/Modal/useTransitionPresence.d.ts.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +124 -49
- package/dist/index.d.ts +1 -1
- 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 +124 -49
- package/dist/layered.css.map +1 -1
- package/package.json +5 -6
- package/src/__tests__/token-v1-compat.test.ts +0 -1
- package/src/_lib/useIsomorphicLayoutEffect.ts +7 -0
- package/src/components/Carousel/MIGRATION.md +39 -39
- package/src/components/Carousel/__snapshots__/index.css.snap +63 -39
- package/src/components/Carousel/index.browser.test.tsx +102 -0
- package/src/components/Carousel/index.css +68 -46
- package/src/components/Carousel/index.story.tsx +87 -68
- package/src/components/Carousel/index.test.tsx +120 -47
- package/src/components/Carousel/index.tsx +30 -14
- package/src/components/Carousel/useCarouselScroller.ts +8 -12
- package/src/components/FieldLabel/__snapshots__/index.css.snap +1 -1
- package/src/components/FieldLabel/index.css +1 -1
- package/src/components/IconButton/__snapshots__/index.css.snap +1 -1
- package/src/components/IconButton/index.css +1 -1
- package/src/components/Modal/__snapshots__/index.css.snap +32 -1
- package/src/components/Modal/index.css +34 -0
- package/src/components/Modal/index.test.tsx +200 -0
- package/src/components/Modal/index.tsx +48 -78
- package/src/components/Modal/useTransitionPresence.ts +95 -0
- 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
|
|
5
|
+
import Carousel, { type CarouselHandlerRef } from '.'
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
244
|
+
{slides.map((slide, i) => (
|
|
229
245
|
<CarouselSlide
|
|
230
|
-
key={
|
|
246
|
+
key={slideKeys[i]}
|
|
231
247
|
index={i}
|
|
232
248
|
store={store}
|
|
233
249
|
onResize={onItemResize}
|
|
234
250
|
>
|
|
235
|
-
{
|
|
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
|
-
{
|
|
279
|
+
{slides.map((_, i) => (
|
|
264
280
|
<CarouselIndicatorItem
|
|
265
|
-
key={
|
|
281
|
+
key={slideKeys[i]}
|
|
266
282
|
index={i}
|
|
267
283
|
isActive={i === activeIndex}
|
|
268
284
|
onSelect={scrollToItem}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
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 変化で貼り直し。
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|