@charcoal-ui/react 6.0.0-rc.5 → 6.0.0-rc.7
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/index.d.ts +2 -0
- package/dist/components/Carousel/index.d.ts.map +1 -1
- package/dist/components/TagItem/index.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -12
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/layered.css +59 -12
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/components/Carousel/MIGRATION.md +8 -8
- package/src/components/Carousel/__snapshots__/index.css.snap +28 -4
- package/src/components/Carousel/index.browser.test.tsx +102 -0
- package/src/components/Carousel/index.css +28 -4
- package/src/components/Carousel/index.story.tsx +14 -12
- package/src/components/Carousel/index.test.tsx +76 -0
- package/src/components/Carousel/index.tsx +24 -0
- package/src/components/TagItem/index.test.tsx +38 -0
- package/src/components/TagItem/index.tsx +1 -0
- package/src/components/TextArea/__snapshots__/index.css.snap +29 -8
- package/src/components/TextArea/index.css +30 -8
- package/src/components/TextArea/index.tsx +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
.charcoal-carousel {
|
|
2
|
+
--charcoal-carousel-gap: 0px;
|
|
2
3
|
position: relative;
|
|
3
4
|
display: block;
|
|
4
5
|
}
|
|
@@ -19,12 +20,13 @@
|
|
|
19
20
|
box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/*
|
|
23
|
-
|
|
23
|
+
/* スライド間隔は gap prop(--charcoal-carousel-gap)で注入する。
|
|
24
|
+
スライド寸法は sandbox 同様、利用者が children 側で持つ。 */
|
|
24
25
|
|
|
25
26
|
.charcoal-carousel__scroller {
|
|
26
27
|
position: relative;
|
|
27
28
|
display: flex;
|
|
29
|
+
gap: var(--charcoal-carousel-gap);
|
|
28
30
|
overflow-x: auto;
|
|
29
31
|
overflow-y: hidden;
|
|
30
32
|
overscroll-behavior-x: contain;
|
|
@@ -130,9 +132,11 @@
|
|
|
130
132
|
display: none;
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
/* hover
|
|
135
|
+
/* hover で表示。キーボード操作でも到達できるよう、キーボード由来のフォーカスが
|
|
136
|
+
カルーセル内にある間も表示する(:focus-within だとクリックで残ったフォーカスでも
|
|
137
|
+
表示され続けるため、react-aria が付与する data 属性で判定する)。 */
|
|
134
138
|
|
|
135
|
-
.charcoal-carousel:hover .charcoal-carousel__navigation, .charcoal-carousel
|
|
139
|
+
.charcoal-carousel:hover .charcoal-carousel__navigation, .charcoal-carousel[data-focus-visible-within] .charcoal-carousel__navigation {
|
|
136
140
|
opacity: 1;
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -140,16 +144,36 @@
|
|
|
140
144
|
pointer-events: auto;
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
/* ヒットエリアをグリッド端列(= mask のフェード帯と同じ 72px)全体へ広げる。
|
|
148
|
+
擬似要素上のクリックは元の button に届くため、帯のどこを押してもページ送りになる。
|
|
149
|
+
position: absolute の基準は positioned な .charcoal-carousel__navigation(inset: 0)。 */
|
|
150
|
+
|
|
151
|
+
.charcoal-carousel__navigation__item::after {
|
|
152
|
+
content: '';
|
|
153
|
+
position: absolute;
|
|
154
|
+
top: 0;
|
|
155
|
+
bottom: 0;
|
|
156
|
+
width: 72px;
|
|
157
|
+
}
|
|
158
|
+
|
|
143
159
|
.charcoal-carousel__navigation__item[data-direction='prev'] {
|
|
144
160
|
grid-column: 1;
|
|
145
161
|
justify-self: center;
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
.charcoal-carousel__navigation__item[data-direction='prev']::after {
|
|
165
|
+
left: 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
148
168
|
.charcoal-carousel__navigation__item[data-direction='next'] {
|
|
149
169
|
grid-column: 3;
|
|
150
170
|
justify-self: center;
|
|
151
171
|
}
|
|
152
172
|
|
|
173
|
+
.charcoal-carousel__navigation__item[data-direction='next']::after {
|
|
174
|
+
right: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
/* スクロール端では非表示。IconButton の disabled スタイル(opacity:0.32)より
|
|
154
178
|
高い詳細度で打ち消すため、ルートを前置する。 */
|
|
155
179
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ナビゲーションボタンのヒットエリア検証(実ブラウザ)
|
|
3
|
+
*
|
|
4
|
+
* hasGradient の 72px フェード帯はページ送り操作の領域に見えるが、
|
|
5
|
+
* クリック可能域が中央の 32px 円形ボタンだけだと、帯の残り部分の
|
|
6
|
+
* クリックが背後のスライド(リンク等)に素通りしてしまう。
|
|
7
|
+
* 帯全体がナビゲーションボタンのヒットエリアであることを
|
|
8
|
+
* document.elementFromPoint のヒットテストで検証する。
|
|
9
|
+
* (pointer-events / 擬似要素のヒットテストは jsdom では再現できない)
|
|
10
|
+
*/
|
|
11
|
+
import { userEvent } from '@vitest/browser/context'
|
|
12
|
+
import { render } from '@testing-library/react'
|
|
13
|
+
import Carousel from '.'
|
|
14
|
+
|
|
15
|
+
const EDGE_ZONE_WIDTH = 72
|
|
16
|
+
|
|
17
|
+
const slides = Array.from({ length: 10 }, (_, i) => (
|
|
18
|
+
<div key={`slide-${i}`} style={{ width: 200, height: 160 }}>
|
|
19
|
+
Slide {i}
|
|
20
|
+
</div>
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
function renderCarousel() {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<div style={{ width: 400 }}>
|
|
26
|
+
<Carousel size="M" hasGradient>
|
|
27
|
+
{slides}
|
|
28
|
+
</Carousel>
|
|
29
|
+
</div>,
|
|
30
|
+
)
|
|
31
|
+
const viewport = container.querySelector(
|
|
32
|
+
'.charcoal-carousel__viewport',
|
|
33
|
+
) as HTMLElement
|
|
34
|
+
return { container, viewport, rect: viewport.getBoundingClientRect() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hitNavigationItem(x: number, y: number) {
|
|
38
|
+
return document
|
|
39
|
+
.elementFromPoint(x, y)
|
|
40
|
+
?.closest('.charcoal-carousel__navigation__item')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('navigation button hit area', () => {
|
|
44
|
+
it('右端 72px 帯のクリックはスライドではなく next ボタンに届く', () => {
|
|
45
|
+
const { rect } = renderCarousel()
|
|
46
|
+
const centerY = rect.top + rect.height / 2
|
|
47
|
+
|
|
48
|
+
// ボタン円(32px、帯の中央)の外側かつ帯の内側の点
|
|
49
|
+
const nearEdge = hitNavigationItem(rect.right - 8, centerY)
|
|
50
|
+
expect(nearEdge?.getAttribute('data-direction')).toBe('next')
|
|
51
|
+
|
|
52
|
+
// ボタンの上下方向の外側(帯の上端付近)
|
|
53
|
+
const aboveButton = hitNavigationItem(
|
|
54
|
+
rect.right - EDGE_ZONE_WIDTH / 2,
|
|
55
|
+
rect.top + 8,
|
|
56
|
+
)
|
|
57
|
+
expect(aboveButton?.getAttribute('data-direction')).toBe('next')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('スクロール不能な側(初期位置の prev)の帯はスライドへ素通りする', () => {
|
|
61
|
+
const { rect } = renderCarousel()
|
|
62
|
+
const centerY = rect.top + rect.height / 2
|
|
63
|
+
|
|
64
|
+
const hit = document.elementFromPoint(rect.left + 8, centerY)
|
|
65
|
+
expect(hit?.closest('.charcoal-carousel__navigation__item')).toBeNull()
|
|
66
|
+
expect(hit?.closest('.charcoal-carousel__scroller')).not.toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('帯のボタン外の点をクリックすると next へページ送りされる', async () => {
|
|
70
|
+
const { container, viewport, rect } = renderCarousel()
|
|
71
|
+
const scroller = container.querySelector(
|
|
72
|
+
'.charcoal-carousel__scroller',
|
|
73
|
+
) as HTMLElement
|
|
74
|
+
expect(scroller.scrollLeft).toBe(0)
|
|
75
|
+
|
|
76
|
+
// trusted click とその後の scroll イベントは React の act() 管理外で
|
|
77
|
+
// 発火するため、この区間だけ act 警告を無効化する。
|
|
78
|
+
const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
79
|
+
g.IS_REACT_ACT_ENVIRONMENT = false
|
|
80
|
+
try {
|
|
81
|
+
// viewport 左上基準・帯の内側かつボタン円の外側の座標を実クリックする
|
|
82
|
+
await userEvent.click(viewport, {
|
|
83
|
+
position: { x: rect.width - 8, y: 8 },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await vi.waitFor(() => {
|
|
87
|
+
expect(scroller.scrollLeft).toBeGreaterThan(0)
|
|
88
|
+
})
|
|
89
|
+
} finally {
|
|
90
|
+
g.IS_REACT_ACT_ENVIRONMENT = true
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('帯の外側(中央領域)のクリックはスライドに届く', () => {
|
|
95
|
+
const { rect } = renderCarousel()
|
|
96
|
+
const centerY = rect.top + rect.height / 2
|
|
97
|
+
|
|
98
|
+
const hit = document.elementFromPoint(rect.left + rect.width / 2, centerY)
|
|
99
|
+
expect(hit?.closest('.charcoal-carousel__navigation__item')).toBeNull()
|
|
100
|
+
expect(hit?.closest('.charcoal-carousel__scroller')).not.toBeNull()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
.charcoal-carousel {
|
|
2
|
+
--charcoal-carousel-gap: 0px;
|
|
3
|
+
|
|
2
4
|
position: relative;
|
|
3
5
|
display: block;
|
|
4
6
|
|
|
@@ -18,11 +20,12 @@
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/*
|
|
22
|
-
|
|
23
|
+
/* スライド間隔は gap prop(--charcoal-carousel-gap)で注入する。
|
|
24
|
+
スライド寸法は sandbox 同様、利用者が children 側で持つ。 */
|
|
23
25
|
.charcoal-carousel__scroller {
|
|
24
26
|
position: relative;
|
|
25
27
|
display: flex;
|
|
28
|
+
gap: var(--charcoal-carousel-gap);
|
|
26
29
|
overflow-x: auto;
|
|
27
30
|
overflow-y: hidden;
|
|
28
31
|
overscroll-behavior-x: contain;
|
|
@@ -134,23 +137,44 @@
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
/* hover
|
|
140
|
+
/* hover で表示。キーボード操作でも到達できるよう、キーボード由来のフォーカスが
|
|
141
|
+
カルーセル内にある間も表示する(:focus-within だとクリックで残ったフォーカスでも
|
|
142
|
+
表示され続けるため、react-aria が付与する data 属性で判定する)。 */
|
|
138
143
|
.charcoal-carousel:hover .charcoal-carousel__navigation,
|
|
139
|
-
.charcoal-carousel
|
|
144
|
+
.charcoal-carousel[data-focus-visible-within] .charcoal-carousel__navigation {
|
|
140
145
|
opacity: 1;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
.charcoal-carousel__navigation__item {
|
|
144
149
|
pointer-events: auto;
|
|
145
150
|
|
|
151
|
+
/* ヒットエリアをグリッド端列(= mask のフェード帯と同じ 72px)全体へ広げる。
|
|
152
|
+
擬似要素上のクリックは元の button に届くため、帯のどこを押してもページ送りになる。
|
|
153
|
+
position: absolute の基準は positioned な .charcoal-carousel__navigation(inset: 0)。 */
|
|
154
|
+
&::after {
|
|
155
|
+
content: '';
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: 0;
|
|
158
|
+
bottom: 0;
|
|
159
|
+
width: 72px;
|
|
160
|
+
}
|
|
161
|
+
|
|
146
162
|
&[data-direction='prev'] {
|
|
147
163
|
grid-column: 1;
|
|
148
164
|
justify-self: center;
|
|
165
|
+
|
|
166
|
+
&::after {
|
|
167
|
+
left: 0;
|
|
168
|
+
}
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
&[data-direction='next'] {
|
|
152
172
|
grid-column: 3;
|
|
153
173
|
justify-self: center;
|
|
174
|
+
|
|
175
|
+
&::after {
|
|
176
|
+
right: 0;
|
|
177
|
+
}
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
180
|
|
|
@@ -16,25 +16,18 @@ const makeSampleImages = (style: CSSProperties) =>
|
|
|
16
16
|
/>
|
|
17
17
|
))
|
|
18
18
|
|
|
19
|
-
// M 用:
|
|
20
|
-
|
|
21
|
-
const spacedImages = makeSampleImages({
|
|
22
|
-
width: 280,
|
|
23
|
-
height: 210,
|
|
24
|
-
marginInlineEnd: 24,
|
|
25
|
-
})
|
|
19
|
+
// M 用: スライド間隔は gap prop で注入する(既定 args の gap: 24)。
|
|
20
|
+
const spacedImages = makeSampleImages({ width: 280, height: 210 })
|
|
26
21
|
// S 用: 1 枚全幅ページングのため間隔なし
|
|
27
22
|
const fullWidthImages = makeSampleImages({ width: '100%', height: '100%' })
|
|
28
23
|
|
|
29
24
|
// 横幅が確定したスロット(defaultScroll の初期位置計算と 0.75 ページ送りを確認するため)。
|
|
30
|
-
// スライド間隔はコンポーネントが所有しないので、利用者側の margin で注入する。
|
|
31
25
|
const numberedSlides = Array.from({ length: 20 }, (_, i) => (
|
|
32
26
|
<div
|
|
33
27
|
key={`num-${i + 1}`}
|
|
34
28
|
style={{
|
|
35
29
|
width: 200,
|
|
36
30
|
height: 120,
|
|
37
|
-
marginInlineEnd: 24,
|
|
38
31
|
display: 'flex',
|
|
39
32
|
alignItems: 'center',
|
|
40
33
|
justifyContent: 'center',
|
|
@@ -60,6 +53,7 @@ export default {
|
|
|
60
53
|
hasGradient: false,
|
|
61
54
|
fullWidth: false,
|
|
62
55
|
scrollStep: 0.75,
|
|
56
|
+
gap: 24,
|
|
63
57
|
},
|
|
64
58
|
argTypes: {
|
|
65
59
|
children: { control: false },
|
|
@@ -80,6 +74,10 @@ export default {
|
|
|
80
74
|
// Controls では数値(比率)のみ操作可能。関数形式は ScrollStepFunction story を参照。
|
|
81
75
|
control: { type: 'range', min: 0.1, max: 1.5, step: 0.05 },
|
|
82
76
|
},
|
|
77
|
+
gap: {
|
|
78
|
+
// number は px。'1rem' などの CSS 値文字列も渡せる。
|
|
79
|
+
control: { type: 'number' },
|
|
80
|
+
},
|
|
83
81
|
},
|
|
84
82
|
} satisfies Meta<typeof Carousel>
|
|
85
83
|
|
|
@@ -88,7 +86,7 @@ export const SizeM: StoryObj<typeof Carousel> = {
|
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
export const SizeS: StoryObj<typeof Carousel> = {
|
|
91
|
-
args: { size: 'S', children: fullWidthImages },
|
|
89
|
+
args: { size: 'S', children: fullWidthImages, gap: 0 },
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
export const WithGradient: StoryObj<typeof Carousel> = {
|
|
@@ -100,7 +98,12 @@ export const FullWidth: StoryObj<typeof Carousel> = {
|
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
export const NavigationButtonsOnSizeS: StoryObj<typeof Carousel> = {
|
|
103
|
-
args: {
|
|
101
|
+
args: {
|
|
102
|
+
size: 'S',
|
|
103
|
+
navigationButtons: true,
|
|
104
|
+
children: fullWidthImages,
|
|
105
|
+
gap: 0,
|
|
106
|
+
},
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
export const IndicatorOnSizeM: StoryObj<typeof Carousel> = {
|
|
@@ -140,7 +143,6 @@ const darkTiles = Array.from({ length: 10 }, (_, i) => (
|
|
|
140
143
|
style={{
|
|
141
144
|
width: 220,
|
|
142
145
|
height: 140,
|
|
143
|
-
marginInlineEnd: 24,
|
|
144
146
|
display: 'flex',
|
|
145
147
|
alignItems: 'center',
|
|
146
148
|
justifyContent: 'center',
|
|
@@ -92,6 +92,30 @@ describe('Carousel', () => {
|
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
+
describe('gap', () => {
|
|
96
|
+
it('sets --charcoal-carousel-gap in px when gap is a number', () => {
|
|
97
|
+
const { container } = render(<Carousel gap={24}>{slides}</Carousel>)
|
|
98
|
+
const root = container.querySelector('.charcoal-carousel') as HTMLElement
|
|
99
|
+
expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe(
|
|
100
|
+
'24px',
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('passes string gap values through as-is', () => {
|
|
105
|
+
const { container } = render(<Carousel gap="1rem">{slides}</Carousel>)
|
|
106
|
+
const root = container.querySelector('.charcoal-carousel') as HTMLElement
|
|
107
|
+
expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe(
|
|
108
|
+
'1rem',
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not set the custom property when gap is not specified', () => {
|
|
113
|
+
const { container } = render(<Carousel>{slides}</Carousel>)
|
|
114
|
+
const root = container.querySelector('.charcoal-carousel') as HTMLElement
|
|
115
|
+
expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe('')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
95
119
|
describe('Size M (default)', () => {
|
|
96
120
|
it('shows navigation buttons', () => {
|
|
97
121
|
render(<Carousel size="M">{slides}</Carousel>)
|
|
@@ -634,6 +658,58 @@ describe('Carousel', () => {
|
|
|
634
658
|
})
|
|
635
659
|
})
|
|
636
660
|
|
|
661
|
+
describe('navigation button visibility (focus modality)', () => {
|
|
662
|
+
// jsdom はジオメトリが全て 0 で Next ボタンが disabled(=フォーカス不可)の
|
|
663
|
+
// ままになるため、スクロール余地をモックして有効化してからフォーカスする。
|
|
664
|
+
function renderWithFocusableNext() {
|
|
665
|
+
render(<Carousel size="M">{slides}</Carousel>)
|
|
666
|
+
const scroller = getScroller()
|
|
667
|
+
mockScrollerGeometry(scroller, { scrollLeft: 0 })
|
|
668
|
+
scroller.scrollBy = vi.fn()
|
|
669
|
+
act(() => {
|
|
670
|
+
fireEvent.scroll(scroller)
|
|
671
|
+
})
|
|
672
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
673
|
+
expect(next).not.toBeDisabled()
|
|
674
|
+
return next
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// react-aria は document 上の keydown / pointerdown でフォーカスの由来
|
|
678
|
+
// (keyboard / pointer)を判定するため、focus() の前にイベントを発火して
|
|
679
|
+
// モダリティを切り替える。
|
|
680
|
+
it('does not mark focus-visible when a button is focused by pointer', () => {
|
|
681
|
+
const next = renderWithFocusableNext()
|
|
682
|
+
fireEvent.pointerDown(next)
|
|
683
|
+
fireEvent.mouseDown(next)
|
|
684
|
+
act(() => next.focus())
|
|
685
|
+
fireEvent.click(next)
|
|
686
|
+
expect(document.activeElement).toBe(next)
|
|
687
|
+
expect(screen.getByRole('region')).not.toHaveAttribute(
|
|
688
|
+
'data-focus-visible-within',
|
|
689
|
+
)
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it('marks focus-visible when a button is focused by keyboard', () => {
|
|
693
|
+
const next = renderWithFocusableNext()
|
|
694
|
+
fireEvent.keyDown(document.body, { key: 'Tab' })
|
|
695
|
+
act(() => next.focus())
|
|
696
|
+
expect(screen.getByRole('region')).toHaveAttribute(
|
|
697
|
+
'data-focus-visible-within',
|
|
698
|
+
'true',
|
|
699
|
+
)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('clears focus-visible when focus leaves the carousel', () => {
|
|
703
|
+
const next = renderWithFocusableNext()
|
|
704
|
+
fireEvent.keyDown(document.body, { key: 'Tab' })
|
|
705
|
+
act(() => next.focus())
|
|
706
|
+
act(() => next.blur())
|
|
707
|
+
expect(screen.getByRole('region')).not.toHaveAttribute(
|
|
708
|
+
'data-focus-visible-within',
|
|
709
|
+
)
|
|
710
|
+
})
|
|
711
|
+
})
|
|
712
|
+
|
|
637
713
|
describe('SSR (server rendering)', () => {
|
|
638
714
|
// jsdom では window が定義されているため renderToString 時に
|
|
639
715
|
// 「useLayoutEffect does nothing on the server」警告が出る(実 SSR=window 無しでは
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
memo,
|
|
8
8
|
useCallback,
|
|
9
9
|
useImperativeHandle,
|
|
10
|
+
useMemo,
|
|
10
11
|
useRef,
|
|
11
12
|
useState,
|
|
12
13
|
useSyncExternalStore,
|
|
14
|
+
type CSSProperties,
|
|
13
15
|
type ReactNode,
|
|
14
16
|
} from 'react'
|
|
15
17
|
import { mergeProps, useFocusRing, useKeyboard } from 'react-aria'
|
|
@@ -67,6 +69,8 @@ export type CarouselProps = Readonly<{
|
|
|
67
69
|
onResize?: (width: number) => void
|
|
68
70
|
onScrollStateChange?: (canScroll: boolean) => void
|
|
69
71
|
defaultScroll?: { align?: ScrollAlign; offset?: number }
|
|
72
|
+
// スライド間隔。number は px、string は CSS 値をそのまま使う。未指定は間隔なし。
|
|
73
|
+
gap?: number | string
|
|
70
74
|
// 1 直接子要素 = 1 スライド(react-sandbox 互換)。
|
|
71
75
|
children: ReactNode
|
|
72
76
|
}>
|
|
@@ -147,6 +151,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
147
151
|
onResize,
|
|
148
152
|
onScrollStateChange,
|
|
149
153
|
defaultScroll: { align = 'left', offset = 0 } = {},
|
|
154
|
+
gap,
|
|
150
155
|
children,
|
|
151
156
|
...props
|
|
152
157
|
}: CarouselProps,
|
|
@@ -208,9 +213,27 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
208
213
|
isFocusVisible: scrollerFocusVisible,
|
|
209
214
|
} = useFocusRing()
|
|
210
215
|
|
|
216
|
+
// ナビゲーションボタン表示用。クリックで残留したフォーカス(pointer 由来)では
|
|
217
|
+
// 表示し続けないよう、キーボード由来のフォーカスのみを検知する。
|
|
218
|
+
const { focusProps: rootFocusProps, isFocusVisible: rootFocusVisible } =
|
|
219
|
+
useFocusRing({ within: true })
|
|
220
|
+
|
|
221
|
+
// gap 宣言自体は index.css 側に置き、ここでは CSS 変数の値だけを注入する。
|
|
222
|
+
const gapStyle = useMemo(
|
|
223
|
+
() =>
|
|
224
|
+
({
|
|
225
|
+
...(gap != null && {
|
|
226
|
+
'--charcoal-carousel-gap': typeof gap === 'number' ? `${gap}px` : gap,
|
|
227
|
+
}),
|
|
228
|
+
}) satisfies CSSProperties,
|
|
229
|
+
[gap],
|
|
230
|
+
)
|
|
231
|
+
|
|
211
232
|
return (
|
|
212
233
|
<div
|
|
234
|
+
{...rootFocusProps}
|
|
213
235
|
className={className}
|
|
236
|
+
style={gapStyle}
|
|
214
237
|
data-size={size}
|
|
215
238
|
data-has-gradient={hasGradient}
|
|
216
239
|
data-full-width={fullWidth}
|
|
@@ -219,6 +242,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
219
242
|
data-scroll-snap-align={snapAlign}
|
|
220
243
|
data-can-prev={canPrev}
|
|
221
244
|
data-can-next={canNext}
|
|
245
|
+
data-focus-visible-within={rootFocusVisible || undefined}
|
|
222
246
|
role="region"
|
|
223
247
|
aria-roledescription="carousel"
|
|
224
248
|
aria-label="Carousel"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { forwardRef } from 'react'
|
|
3
|
+
import TagItem from '.'
|
|
4
|
+
|
|
5
|
+
describe('TagItem', () => {
|
|
6
|
+
it('preserves link props on a custom link component', () => {
|
|
7
|
+
const NextLink = forwardRef<
|
|
8
|
+
HTMLAnchorElement,
|
|
9
|
+
React.ComponentPropsWithoutRef<'a'>
|
|
10
|
+
>(function NextLink({ children, ...props }, ref) {
|
|
11
|
+
return (
|
|
12
|
+
<a ref={ref} {...props}>
|
|
13
|
+
{children}
|
|
14
|
+
</a>
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const onClick = vi.fn()
|
|
19
|
+
|
|
20
|
+
render(
|
|
21
|
+
<TagItem
|
|
22
|
+
component={NextLink}
|
|
23
|
+
href="/tags/girl"
|
|
24
|
+
label="#girl"
|
|
25
|
+
target="_blank"
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
/>,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const link = screen.getByRole('link')
|
|
31
|
+
|
|
32
|
+
expect(link).toHaveAttribute('href', '/tags/girl')
|
|
33
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
34
|
+
|
|
35
|
+
fireEvent.click(link)
|
|
36
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
.charcoal-text-area-container:has([data-no-bottom-padding='true']) {
|
|
34
|
+
height: calc(22px * (var(--charcoal-text-area-rows) + 1) + 18px);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
.charcoal-text-area-container:not([aria-disabled='true']):hover {
|
|
34
38
|
background-color: var(--charcoal-color-container-secondary-hover-a);
|
|
35
39
|
}
|
|
@@ -47,13 +51,26 @@
|
|
|
47
51
|
box-sizing: border-box;
|
|
48
52
|
|
|
49
53
|
/* Prevent zooming for iOS Safari */
|
|
54
|
+
--charcoal-textarea-safari-prevent-zoom-scale: 0.875;
|
|
55
|
+
--charcoal-textarea-line-height: calc(
|
|
56
|
+
22px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
57
|
+
);
|
|
58
|
+
--charcoal-textarea-padding-y: calc(
|
|
59
|
+
8px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
60
|
+
);
|
|
61
|
+
--charcoal-textarea-padding-x: calc(
|
|
62
|
+
9px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
63
|
+
);
|
|
50
64
|
transform-origin: top left;
|
|
51
|
-
transform: scale(
|
|
52
|
-
width: calc(100% /
|
|
53
|
-
font-size: calc(14px /
|
|
54
|
-
line-height:
|
|
55
|
-
padding:
|
|
56
|
-
height: calc(
|
|
65
|
+
transform: scale(var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
66
|
+
width: calc(100% / var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
67
|
+
font-size: calc(14px / var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
68
|
+
line-height: var(--charcoal-textarea-line-height);
|
|
69
|
+
padding: var(--charcoal-textarea-padding-y) var(--charcoal-textarea-padding-x);
|
|
70
|
+
height: calc(
|
|
71
|
+
var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
|
|
72
|
+
var(--charcoal-textarea-padding-y) * 2
|
|
73
|
+
);
|
|
57
74
|
|
|
58
75
|
/* Display box-shadow for iOS Safari */
|
|
59
76
|
appearance: none;
|
|
@@ -61,8 +78,12 @@
|
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
.charcoal-text-area-textarea[data-no-bottom-padding='true'] {
|
|
64
|
-
padding:
|
|
65
|
-
|
|
81
|
+
padding: var(--charcoal-textarea-padding-y)
|
|
82
|
+
var(--charcoal-textarea-padding-x) 0;
|
|
83
|
+
height: calc(
|
|
84
|
+
var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
|
|
85
|
+
var(--charcoal-textarea-padding-y)
|
|
86
|
+
);
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
.charcoal-text-area-textarea::placeholder {
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
&:has([data-no-bottom-padding='true']) {
|
|
36
|
+
height: calc(22px * (var(--charcoal-text-area-rows) + 1) + 18px);
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
&:not([aria-disabled='true']):hover {
|
|
36
40
|
background-color: var(--charcoal-color-container-secondary-hover-a);
|
|
37
41
|
}
|
|
@@ -50,13 +54,27 @@
|
|
|
50
54
|
box-sizing: border-box;
|
|
51
55
|
|
|
52
56
|
/* Prevent zooming for iOS Safari */
|
|
57
|
+
--charcoal-textarea-safari-prevent-zoom-scale: 0.875;
|
|
58
|
+
--charcoal-textarea-line-height: calc(
|
|
59
|
+
22px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
60
|
+
);
|
|
61
|
+
--charcoal-textarea-padding-y: calc(
|
|
62
|
+
8px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
63
|
+
);
|
|
64
|
+
--charcoal-textarea-padding-x: calc(
|
|
65
|
+
9px / var(--charcoal-textarea-safari-prevent-zoom-scale)
|
|
66
|
+
);
|
|
67
|
+
|
|
53
68
|
transform-origin: top left;
|
|
54
|
-
transform: scale(
|
|
55
|
-
width: calc(100% /
|
|
56
|
-
font-size: calc(14px /
|
|
57
|
-
line-height:
|
|
58
|
-
padding:
|
|
59
|
-
height: calc(
|
|
69
|
+
transform: scale(var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
70
|
+
width: calc(100% / var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
71
|
+
font-size: calc(14px / var(--charcoal-textarea-safari-prevent-zoom-scale));
|
|
72
|
+
line-height: var(--charcoal-textarea-line-height);
|
|
73
|
+
padding: var(--charcoal-textarea-padding-y) var(--charcoal-textarea-padding-x);
|
|
74
|
+
height: calc(
|
|
75
|
+
var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
|
|
76
|
+
var(--charcoal-textarea-padding-y) * 2
|
|
77
|
+
);
|
|
60
78
|
|
|
61
79
|
/* Display box-shadow for iOS Safari */
|
|
62
80
|
appearance: none;
|
|
@@ -64,8 +82,12 @@
|
|
|
64
82
|
background: none;
|
|
65
83
|
|
|
66
84
|
&[data-no-bottom-padding='true'] {
|
|
67
|
-
padding:
|
|
68
|
-
|
|
85
|
+
padding: var(--charcoal-textarea-padding-y)
|
|
86
|
+
var(--charcoal-textarea-padding-x) 0;
|
|
87
|
+
height: calc(
|
|
88
|
+
var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
|
|
89
|
+
var(--charcoal-textarea-padding-y)
|
|
90
|
+
);
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
&::placeholder {
|
|
@@ -206,7 +206,7 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
|
206
206
|
aria-invalid={invalid === true}
|
|
207
207
|
ref={containerRef}
|
|
208
208
|
style={{
|
|
209
|
-
'--charcoal-text-area-rows':
|
|
209
|
+
'--charcoal-text-area-rows': rows,
|
|
210
210
|
}}
|
|
211
211
|
>
|
|
212
212
|
<textarea
|