@charcoal-ui/react 6.0.0-rc.0 → 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.
- package/dist/components/Carousel/CarouselItem.d.ts +10 -0
- package/dist/components/Carousel/CarouselItem.d.ts.map +1 -0
- package/dist/components/Carousel/carouselStore.d.ts +25 -0
- package/dist/components/Carousel/carouselStore.d.ts.map +1 -0
- package/dist/components/Carousel/index.d.ts +61 -0
- package/dist/components/Carousel/index.d.ts.map +1 -0
- package/dist/components/Carousel/intersectionObserver.d.ts +7 -0
- package/dist/components/Carousel/intersectionObserver.d.ts.map +1 -0
- package/dist/components/Carousel/resizeObserver.d.ts +6 -0
- package/dist/components/Carousel/resizeObserver.d.ts.map +1 -0
- package/dist/components/Carousel/store.d.ts +7 -0
- package/dist/components/Carousel/store.d.ts.map +1 -0
- package/dist/components/Carousel/useCarouselScroller.d.ts +18 -0
- package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -0
- package/dist/components/Modal/index.d.ts +1 -1
- package/dist/components/TagItem/index.d.ts +5 -4
- 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 +275 -0
- package/dist/index.d.ts +3 -2
- 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 +275 -0
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/components/Carousel/CarouselItem.tsx +56 -0
- package/src/components/Carousel/MIGRATION.md +117 -0
- package/src/components/Carousel/Migration.mdx +8 -0
- package/src/components/Carousel/__snapshots__/index.css.snap +274 -0
- package/src/components/Carousel/carouselStore.test.ts +38 -0
- package/src/components/Carousel/carouselStore.ts +48 -0
- package/src/components/Carousel/index.css +274 -0
- package/src/components/Carousel/index.story.tsx +169 -0
- package/src/components/Carousel/index.test.tsx +666 -0
- package/src/components/Carousel/index.tsx +278 -0
- package/src/components/Carousel/intersectionObserver.test.ts +68 -0
- package/src/components/Carousel/intersectionObserver.ts +58 -0
- package/src/components/Carousel/resizeObserver.test.ts +60 -0
- package/src/components/Carousel/resizeObserver.ts +35 -0
- package/src/components/Carousel/store.test.ts +25 -0
- package/src/components/Carousel/store.ts +27 -0
- package/src/components/Carousel/useCarouselScroller.ts +158 -0
- package/src/components/TagItem/__snapshots__/index.story.storyshot +17 -17
- package/src/components/TagItem/index.tsx +25 -7
- package/src/index.ts +14 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import type { CarouselStore } from './carouselStore'
|
|
3
|
+
import { observeCenter } from './intersectionObserver'
|
|
4
|
+
import { observeResize } from './resizeObserver'
|
|
5
|
+
|
|
6
|
+
export type CarouselItemProps = Readonly<{
|
|
7
|
+
index: number
|
|
8
|
+
store: CarouselStore
|
|
9
|
+
onResize: () => void
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
export const CarouselItem = ({
|
|
14
|
+
index,
|
|
15
|
+
store,
|
|
16
|
+
onResize,
|
|
17
|
+
children,
|
|
18
|
+
}: CarouselItemProps) => {
|
|
19
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
20
|
+
|
|
21
|
+
// activeIndex: 自分が中央に来たら store に報告する(root は親=scroller から導出)。
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = ref.current
|
|
24
|
+
if (!el) return
|
|
25
|
+
return observeCenter(el, () => store.dispatch({ type: 'setActive', index }))
|
|
26
|
+
}, [index, store])
|
|
27
|
+
|
|
28
|
+
// scrollToItem: 自分宛ての命令でだけ自己スクロールする(再レンダーしない)。
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let lastNonce = store.getSnapshot().scroll?.nonce ?? 0
|
|
31
|
+
return store.subscribe(() => {
|
|
32
|
+
const scroll = store.getSnapshot().scroll
|
|
33
|
+
if (!scroll || scroll.index !== index || scroll.nonce === lastNonce)
|
|
34
|
+
return
|
|
35
|
+
lastNonce = scroll.nonce
|
|
36
|
+
ref.current?.scrollIntoView({
|
|
37
|
+
behavior: 'smooth',
|
|
38
|
+
inline: 'center',
|
|
39
|
+
block: 'nearest',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}, [index, store])
|
|
43
|
+
|
|
44
|
+
// 遅延コンテンツ: 自分のサイズ変化を onResize で通知する。
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = ref.current
|
|
47
|
+
if (!el) return
|
|
48
|
+
return observeResize(el, onResize)
|
|
49
|
+
}, [onResize])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div ref={ref} className="charcoal-carousel__item">
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Carousel 移行ガイド(`@charcoal-ui/react-sandbox` → `@charcoal-ui/react`)
|
|
2
|
+
|
|
3
|
+
`@charcoal-ui/react-sandbox` の `Carousel`(react-spring + styled-components 実装)から、
|
|
4
|
+
`@charcoal-ui/react` の `Carousel`(CSS scroll-snap + store ベース実装)への移行手順をまとめる。
|
|
5
|
+
|
|
6
|
+
## 移行の要点
|
|
7
|
+
|
|
8
|
+
- **import 差し替え + `children` → `items` がほぼ唯一の必須変更**。
|
|
9
|
+
- `onScroll` / `onResize` / `onScrollStateChange` / `ref`(`resetScroll()`)/ `defaultScroll` /
|
|
10
|
+
`hasGradient` は **sandbox と同じシグネチャ**で利用できる(drop-in 互換)。
|
|
11
|
+
- `scrollAmountCoef` は `scrollStep`(関数も可)に名称変更。`fadeInGradient` / `buttonOffset` 系 /
|
|
12
|
+
`centerItems` は廃止。
|
|
13
|
+
- `size` / `indicator` / `scrollSnap` / `fullWidth` は新規(任意・後方互換)。
|
|
14
|
+
|
|
15
|
+
## 概要
|
|
16
|
+
|
|
17
|
+
| | sandbox (`@charcoal-ui/react-sandbox`) | react (`@charcoal-ui/react`) |
|
|
18
|
+
| -------------- | -------------------------------------- | ----------------------------------------------- |
|
|
19
|
+
| スクロール | react-spring による JS アニメーション | ネイティブ overflow + CSS `scroll-snap` |
|
|
20
|
+
| 子要素 | `children`(任意のノード) | `items: { id; children }[]` |
|
|
21
|
+
| インジケーター | なし | CSS `::scroll-marker` / JS フォールバックの dot |
|
|
22
|
+
| スタイル | styled-components | プレーン CSS(`index.css`) |
|
|
23
|
+
|
|
24
|
+
## import
|
|
25
|
+
|
|
26
|
+
```diff
|
|
27
|
+
- import { Carousel } from '@charcoal-ui/react-sandbox'
|
|
28
|
+
+ import { Carousel } from '@charcoal-ui/react'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
公開型: `CarouselProps` / `CarouselItem` / `CarouselHandlerRef` / `ScrollAlign` / `ScrollStep` /
|
|
32
|
+
`ScrollStepContext` / `ScrollSnap` / `ScrollSnapType` / `ScrollSnapAlign`。
|
|
33
|
+
|
|
34
|
+
## 子要素: `children` → `items`
|
|
35
|
+
|
|
36
|
+
最大の変更点。子ノードを直接渡す形から、`id` 付きの配列に変える。
|
|
37
|
+
sandbox では自前で `flex` ラッパーや `gap` を組んでいたが、新版はレイアウト(`gap: 24px`、S は `0`)を
|
|
38
|
+
コンポーネント側 CSS が持つので、**ラッパーで囲まず 1 スライド 1 要素**で渡す。
|
|
39
|
+
|
|
40
|
+
```diff
|
|
41
|
+
- <Carousel hasGradient defaultScroll={{ align: 'center' }} scrollAmountCoef={0.75}>
|
|
42
|
+
- <div style={{ display: 'flex', gap: 8 }}>
|
|
43
|
+
- {items.map((i) => (
|
|
44
|
+
- <Slide key={i} />
|
|
45
|
+
- ))}
|
|
46
|
+
- </div>
|
|
47
|
+
- </Carousel>
|
|
48
|
+
+ <Carousel
|
|
49
|
+
+ size="M"
|
|
50
|
+
+ hasGradient
|
|
51
|
+
+ defaultScroll={{ align: 'center' }}
|
|
52
|
+
+ scrollStep={0.75}
|
|
53
|
+
+ items={items.map((i) => ({ id: String(i), children: <Slide /> }))}
|
|
54
|
+
+ />
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`id` はキー安定化(再レンダー・スクロール命令の宛先識別)に使うので、配列内で一意かつ安定した値にする。
|
|
58
|
+
|
|
59
|
+
## props 対応表
|
|
60
|
+
|
|
61
|
+
| sandbox | react | 備考 |
|
|
62
|
+
| ------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `children` | `items: { id: string; children: ReactNode }[]` | 上記参照 |
|
|
64
|
+
| `scrollAmountCoef`(既定 `0.75`) | `scrollStep`(既定 `0.75`) | `number`(表示幅比)に加え `(ctx) => px` の関数も渡せる |
|
|
65
|
+
| `defaultScroll: { align, offset }` | `defaultScroll: { align, offset }` | `align` は `'left' \| 'center' \| 'right'`。ほぼ同等 |
|
|
66
|
+
| `hasGradient` | `hasGradient`(既定 `false`) | 実装が mask → 背景色オーバーレイに変更 |
|
|
67
|
+
| `fadeInGradient` | (廃止) | 常にオーバーレイ式フェード |
|
|
68
|
+
| `buttonOffset` / `buttonPadding` / `bottomOffset` | (廃止) | ボタン配置は CSS グリッド(左右 72px ゾーン)に固定 |
|
|
69
|
+
| `centerItems` | (廃止) | レイアウトは `flex` + `gap` 固定 |
|
|
70
|
+
| `onScroll(left)` | `onScroll(left)` | ✅ そのまま対応(scroll で発火) |
|
|
71
|
+
| `onResize(width)` | `onResize(width)` | ✅ scroller 幅の変化で発火 |
|
|
72
|
+
| `onScrollStateChange(canScroll)` | `onScrollStateChange(canScroll)` | ✅ `canPrev \|\| canNext` の変化で発火 |
|
|
73
|
+
| `ref`(`CarouselHandlerRef.resetScroll()`) | `ref`(`CarouselHandlerRef.resetScroll()`) | ✅ `forwardRef` で対応。`defaultScroll` の初期位置へ戻す |
|
|
74
|
+
| — | `size: 'S' \| 'M'`(既定 `'M'`) | 新規。`S` は 1 枚全幅 + `mandatory` スナップ |
|
|
75
|
+
| — | `navigationButtons?: boolean` | 既定は `size === 'M'` |
|
|
76
|
+
| — | `indicator?: boolean` | 既定は `size === 'S'` |
|
|
77
|
+
| — | `fullWidth?: boolean`(既定 `false`) | `100vw` 表示 |
|
|
78
|
+
| — | `className?: string` | ルートに付与 |
|
|
79
|
+
| — | `scrollSnap?: { type?; align? }` | `type`: `none`/`proximity`/`mandatory`、`align`: `center`/`start`。未指定で M=none / S=mandatory / center |
|
|
80
|
+
|
|
81
|
+
## 挙動の変更(移行時に確認すること)
|
|
82
|
+
|
|
83
|
+
- **ナビゲーションボタンの hover 表示を廃止**: sandbox はカルーセルに hover した時だけボタンが現れ、
|
|
84
|
+
マウスが離れるとフェードアウトしていた。新版は**常時表示**で、スクロール端でのみ非表示
|
|
85
|
+
(`canPrev`/`canNext`)。hover-reveal に依存した UI だった場合は要確認。
|
|
86
|
+
- **タッチ端末**: 両実装ともナビゲーションボタンを非表示(`@media (hover: none) and (pointer: coarse)`)。
|
|
87
|
+
- **スナップ**: JS アニメーションから CSS `scroll-snap`(`scroll-snap-align: center`)へ。
|
|
88
|
+
既定は `M` が `none`(スナップなし。`scrollStep`=0.75×表示幅ちょうど進む。sandbox の進み量と一致)、
|
|
89
|
+
`S` が `mandatory`(1 枚全幅で必ずスナップ)。`scrollSnap` prop で `proximity`/`mandatory` も選べる。
|
|
90
|
+
※アニメーションは native smooth scroll で、sandbox(react-spring)とイージング/連打時の積算挙動は異なる。
|
|
91
|
+
- **インジケーター**: 新規。`indicator` 有効時に dot を表示(CSS Scroll Markers 対応環境では `::scroll-marker`、
|
|
92
|
+
非対応環境では JS フォールバック)。
|
|
93
|
+
- **キーボード操作**: スクローラーが `tabIndex={0}` でフォーカス可能になり、`←` / `→` で 1 ステップスクロール。
|
|
94
|
+
フォーカスリングは charcoal 標準(`box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32)`)。
|
|
95
|
+
- **グラデーション**: `mask` による透過から、背景色(`#fff`)オーバーレイ方式に変更。
|
|
96
|
+
ダークモードは現状未対応(背景色固定)。
|
|
97
|
+
|
|
98
|
+
## スクロール量を細かく制御したい場合
|
|
99
|
+
|
|
100
|
+
`scrollStep` に関数を渡すと、1 操作あたりの進む量(px)を自前で計算できる。
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
// 表示幅の比率(sandbox の scrollAmountCoef 相当)
|
|
104
|
+
<Carousel items={items} scrollStep={0.5} />
|
|
105
|
+
|
|
106
|
+
// 進む px を直接返す(端は残り全部、など)
|
|
107
|
+
<Carousel
|
|
108
|
+
items={items}
|
|
109
|
+
scrollStep={({ clientWidth, scrollWidth, scrollLeft, direction }) =>
|
|
110
|
+
direction === 'next'
|
|
111
|
+
? Math.min(clientWidth * 0.8, scrollWidth - clientWidth - scrollLeft)
|
|
112
|
+
: Math.min(clientWidth * 0.8, scrollLeft)
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
戻り値は「進む量の絶対値(px)」。符号(prev / next)はコンポーネント側で付与する。
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
.charcoal-carousel {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: block;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.charcoal-carousel[data-full-width='true'] {
|
|
7
|
+
width: 100vw;
|
|
8
|
+
margin-inline: calc(50% - 50vw);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.charcoal-carousel__viewport {
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.charcoal-carousel__scroller {
|
|
16
|
+
position: relative;
|
|
17
|
+
display: flex;
|
|
18
|
+
gap: 24px;
|
|
19
|
+
overflow-x: auto;
|
|
20
|
+
overflow-y: hidden;
|
|
21
|
+
overscroll-behavior-x: contain;
|
|
22
|
+
scroll-behavior: smooth;
|
|
23
|
+
scrollbar-width: none;
|
|
24
|
+
outline: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.charcoal-carousel__scroller::-webkit-scrollbar {
|
|
28
|
+
display: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.charcoal-carousel__scroller[data-focus-visible] {
|
|
32
|
+
box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.charcoal-carousel[data-size='S'] .charcoal-carousel__scroller {
|
|
36
|
+
gap: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* スクロールスナップ type は prop(scrollSnap.type)で scroller に出し分ける。
|
|
40
|
+
align は基底 .charcoal-carousel__item の後(後述)に置く(no-descending-specificity 回避)。
|
|
41
|
+
未指定時の既定(M=none / S=mandatory / align=center)は JS 側で data 属性に解決する。 */
|
|
42
|
+
|
|
43
|
+
.charcoal-carousel[data-scroll-snap-type='none'] .charcoal-carousel__scroller {
|
|
44
|
+
scroll-snap-type: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.charcoal-carousel[data-scroll-snap-type='proximity'] .charcoal-carousel__scroller {
|
|
48
|
+
scroll-snap-type: x proximity;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.charcoal-carousel[data-scroll-snap-type='mandatory'] .charcoal-carousel__scroller {
|
|
52
|
+
scroll-snap-type: x mandatory;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* グラデーション: はみ出してスクロール可能な側のみ、端のアイテムを背景色(白)へ
|
|
56
|
+
フェードさせる。mask による透過ではなく、72px の「背景色→透明」オーバーレイを
|
|
57
|
+
ビューポートに重ねて白で覆う。スクロール可能な側だけ data-can-prev/next で出し分ける。 */
|
|
58
|
+
|
|
59
|
+
.charcoal-carousel[data-has-gradient='true'] .charcoal-carousel__viewport::before, .charcoal-carousel[data-has-gradient='true'] .charcoal-carousel__viewport::after {
|
|
60
|
+
content: '';
|
|
61
|
+
position: absolute;
|
|
62
|
+
top: 0;
|
|
63
|
+
bottom: 0;
|
|
64
|
+
width: 72px;
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
opacity: 0;
|
|
67
|
+
transition: 0.2s opacity;
|
|
68
|
+
z-index: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.charcoal-carousel[data-has-gradient='true'] .charcoal-carousel__viewport::before {
|
|
72
|
+
left: 0;
|
|
73
|
+
background: linear-gradient(to right, #fff, transparent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.charcoal-carousel[data-has-gradient='true'] .charcoal-carousel__viewport::after {
|
|
77
|
+
right: 0;
|
|
78
|
+
background: linear-gradient(to left, #fff, transparent);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.charcoal-carousel[data-has-gradient='true'][data-can-prev='true'] .charcoal-carousel__viewport::before {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.charcoal-carousel[data-has-gradient='true'][data-can-next='true'] .charcoal-carousel__viewport::after {
|
|
86
|
+
opacity: 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.charcoal-carousel__item {
|
|
90
|
+
flex: 0 0 auto;
|
|
91
|
+
min-width: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.charcoal-carousel[data-size='S'] .charcoal-carousel__item {
|
|
95
|
+
flex: 0 0 100%;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* scroll-snap-align は prop(scrollSnap.align)で出し分ける。基底 .charcoal-carousel__item
|
|
99
|
+
より後に置くことで no-descending-specificity を回避する。 */
|
|
100
|
+
|
|
101
|
+
.charcoal-carousel[data-scroll-snap-align='center'] .charcoal-carousel__item {
|
|
102
|
+
scroll-snap-align: center;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.charcoal-carousel[data-scroll-snap-align='start'] .charcoal-carousel__item {
|
|
106
|
+
scroll-snap-align: start;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── Navigation Buttons (charcoal IconButton, variant=Overlay size=S) ── */
|
|
110
|
+
|
|
111
|
+
/* マスクと同じ 72px 1fr 72px グリッド。各ボタンは 72px ゾーンの中央に置く。 */
|
|
112
|
+
|
|
113
|
+
.charcoal-carousel__navigation {
|
|
114
|
+
position: absolute;
|
|
115
|
+
inset: 0;
|
|
116
|
+
pointer-events: none;
|
|
117
|
+
display: grid;
|
|
118
|
+
grid-template-columns: 72px 1fr 72px;
|
|
119
|
+
align-items: center;
|
|
120
|
+
/* 白フェード(z-index: 1)より上にボタンを表示する */
|
|
121
|
+
z-index: 2;
|
|
122
|
+
/* sandbox 同様、通常は隠してカルーセル hover 時にフェードイン表示する。 */
|
|
123
|
+
opacity: 0;
|
|
124
|
+
transition: 0.4s opacity;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.charcoal-carousel__navigation[data-visible='false'] {
|
|
128
|
+
display: none;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* hover で表示。キーボード操作でも到達できるよう focus-within でも表示する。 */
|
|
132
|
+
|
|
133
|
+
.charcoal-carousel:hover .charcoal-carousel__navigation, .charcoal-carousel:focus-within .charcoal-carousel__navigation {
|
|
134
|
+
opacity: 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.charcoal-carousel__navigation__item {
|
|
138
|
+
pointer-events: auto;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.charcoal-carousel__navigation__item[data-direction='prev'] {
|
|
142
|
+
grid-column: 1;
|
|
143
|
+
justify-self: center;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.charcoal-carousel__navigation__item[data-direction='next'] {
|
|
147
|
+
grid-column: 3;
|
|
148
|
+
justify-self: center;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* スクロール端では非表示。IconButton の disabled スタイル(opacity:0.32)より
|
|
152
|
+
高い詳細度で打ち消すため、ルートを前置する。 */
|
|
153
|
+
|
|
154
|
+
.charcoal-carousel .charcoal-carousel__navigation__item[data-hidden='true'] {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
pointer-events: none;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* タッチデバイスではナビゲーションボタンを出さず、スワイプ操作に委ねる。 */
|
|
160
|
+
|
|
161
|
+
@media (hover: none) and (pointer: coarse) {
|
|
162
|
+
|
|
163
|
+
.charcoal-carousel__navigation {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── Indicator: JS Fallback ── */
|
|
169
|
+
|
|
170
|
+
.charcoal-carousel__indicator {
|
|
171
|
+
display: flex;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
align-items: center;
|
|
174
|
+
height: 40px;
|
|
175
|
+
gap: 8px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.charcoal-carousel__indicator[data-visible='false'] {
|
|
179
|
+
display: none;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.charcoal-carousel__indicator__item {
|
|
183
|
+
appearance: none;
|
|
184
|
+
box-sizing: border-box;
|
|
185
|
+
width: 8px;
|
|
186
|
+
height: 8px;
|
|
187
|
+
padding: 0;
|
|
188
|
+
border: 0;
|
|
189
|
+
border-radius: 50%;
|
|
190
|
+
background-color: var(--charcoal-color-text-tertiary-default);
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
transition: 0.2s background-color;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.charcoal-carousel__indicator__item:hover {
|
|
196
|
+
background-color: var(--charcoal-color-text-secondary-default);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.charcoal-carousel__indicator__item:focus-visible {
|
|
200
|
+
outline: 2px solid rgba(0, 150, 250, 0.56);
|
|
201
|
+
outline-offset: 2px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.charcoal-carousel__indicator__item[data-active='true'] {
|
|
205
|
+
background-color: var(--charcoal-color-text-default);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ── CSS Scroll Markers (progressive enhancement) ── */
|
|
209
|
+
|
|
210
|
+
/* CSS Scroll Markers / Anchor Positioning は新しい仕様で、stylelint がプロパティ・
|
|
211
|
+
擬似要素・擬似クラスを未知と判定するため、この @supports ブロックでは該当ルールを無効化する。 */
|
|
212
|
+
/* stylelint-disable property-no-unknown, selector-pseudo-element-no-unknown, selector-pseudo-class-no-unknown */
|
|
213
|
+
|
|
214
|
+
@supports (scroll-marker-group: after) {
|
|
215
|
+
|
|
216
|
+
.charcoal-carousel[data-indicator='true'] {
|
|
217
|
+
padding-bottom: 40px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.charcoal-carousel__scroller {
|
|
221
|
+
anchor-name: --charcoal-carousel;
|
|
222
|
+
scroll-marker-group: after;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.charcoal-carousel__scroller::scroll-button(*) {
|
|
226
|
+
content: none;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.charcoal-carousel__scroller::scroll-marker-group {
|
|
230
|
+
display: flex;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
align-items: center;
|
|
233
|
+
height: 40px;
|
|
234
|
+
gap: 8px;
|
|
235
|
+
position: absolute;
|
|
236
|
+
position-anchor: --charcoal-carousel;
|
|
237
|
+
top: anchor(bottom);
|
|
238
|
+
justify-self: anchor-center;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.charcoal-carousel[data-indicator='false'] .charcoal-carousel__scroller {
|
|
242
|
+
scroll-marker-group: none;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.charcoal-carousel__item::scroll-marker {
|
|
246
|
+
content: '';
|
|
247
|
+
width: 8px;
|
|
248
|
+
height: 8px;
|
|
249
|
+
border: 0;
|
|
250
|
+
border-radius: 50%;
|
|
251
|
+
background-color: var(--charcoal-color-text-tertiary-default);
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
transition: 0.2s background-color;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.charcoal-carousel__item::scroll-marker:hover {
|
|
257
|
+
background-color: var(--charcoal-color-text-secondary-default);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.charcoal-carousel__item::scroll-marker:focus {
|
|
261
|
+
outline: 2px solid rgba(0, 150, 250, 0.56);
|
|
262
|
+
outline-offset: 2px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.charcoal-carousel__item::scroll-marker:target-current {
|
|
266
|
+
background-color: var(--charcoal-color-text-default);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.charcoal-carousel__indicator {
|
|
270
|
+
display: none;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/* stylelint-enable property-no-unknown, selector-pseudo-element-no-unknown, selector-pseudo-class-no-unknown */
|
|
274
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createCarouselStore } from './carouselStore'
|
|
3
|
+
|
|
4
|
+
describe('createCarouselStore', () => {
|
|
5
|
+
it('初期値', () => {
|
|
6
|
+
expect(createCarouselStore().getSnapshot()).toEqual({
|
|
7
|
+
activeIndex: 0,
|
|
8
|
+
canPrev: false,
|
|
9
|
+
canNext: false,
|
|
10
|
+
scroll: null,
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('setActive: 変化なしなら同一参照', () => {
|
|
15
|
+
const s = createCarouselStore()
|
|
16
|
+
const a = s.getSnapshot()
|
|
17
|
+
s.dispatch({ type: 'setActive', index: 0 })
|
|
18
|
+
expect(s.getSnapshot()).toBe(a)
|
|
19
|
+
s.dispatch({ type: 'setActive', index: 2 })
|
|
20
|
+
expect(s.getSnapshot().activeIndex).toBe(2)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('setScrollState: 変化なしなら同一参照', () => {
|
|
24
|
+
const s = createCarouselStore()
|
|
25
|
+
s.dispatch({ type: 'setScrollState', canPrev: true, canNext: true })
|
|
26
|
+
const a = s.getSnapshot()
|
|
27
|
+
s.dispatch({ type: 'setScrollState', canPrev: true, canNext: true })
|
|
28
|
+
expect(s.getSnapshot()).toBe(a)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('requestScroll: index と単調増加 nonce', () => {
|
|
32
|
+
const s = createCarouselStore()
|
|
33
|
+
s.dispatch({ type: 'requestScroll', index: 3 })
|
|
34
|
+
expect(s.getSnapshot().scroll).toEqual({ index: 3, nonce: 1 })
|
|
35
|
+
s.dispatch({ type: 'requestScroll', index: 3 })
|
|
36
|
+
expect(s.getSnapshot().scroll).toEqual({ index: 3, nonce: 2 })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createStore, type Store } from './store'
|
|
2
|
+
|
|
3
|
+
export type CarouselState = Readonly<{
|
|
4
|
+
activeIndex: number
|
|
5
|
+
canPrev: boolean
|
|
6
|
+
canNext: boolean
|
|
7
|
+
scroll: { index: number; nonce: number } | null
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
export type CarouselAction =
|
|
11
|
+
| { type: 'setActive'; index: number }
|
|
12
|
+
| { type: 'setScrollState'; canPrev: boolean; canNext: boolean }
|
|
13
|
+
| { type: 'requestScroll'; index: number }
|
|
14
|
+
|
|
15
|
+
export type CarouselStore = Store<CarouselState, CarouselAction>
|
|
16
|
+
|
|
17
|
+
export const INITIAL_CAROUSEL_STATE: CarouselState = {
|
|
18
|
+
activeIndex: 0,
|
|
19
|
+
canPrev: false,
|
|
20
|
+
canNext: false,
|
|
21
|
+
scroll: null,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reducer = (
|
|
25
|
+
state: CarouselState,
|
|
26
|
+
action: CarouselAction,
|
|
27
|
+
): CarouselState => {
|
|
28
|
+
switch (action.type) {
|
|
29
|
+
case 'setActive':
|
|
30
|
+
return state.activeIndex === action.index
|
|
31
|
+
? state
|
|
32
|
+
: { ...state, activeIndex: action.index }
|
|
33
|
+
case 'setScrollState':
|
|
34
|
+
return state.canPrev === action.canPrev &&
|
|
35
|
+
state.canNext === action.canNext
|
|
36
|
+
? state
|
|
37
|
+
: { ...state, canPrev: action.canPrev, canNext: action.canNext }
|
|
38
|
+
case 'requestScroll':
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
scroll: { index: action.index, nonce: (state.scroll?.nonce ?? 0) + 1 },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createCarouselStore(): CarouselStore {
|
|
47
|
+
return createStore(reducer, INITIAL_CAROUSEL_STATE)
|
|
48
|
+
}
|