@charcoal-ui/react 6.0.0-rc.1 → 6.0.0-rc.3

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 (50) hide show
  1. package/dist/components/Carousel/CarouselItem.d.ts +10 -0
  2. package/dist/components/Carousel/CarouselItem.d.ts.map +1 -0
  3. package/dist/components/Carousel/carouselStore.d.ts +25 -0
  4. package/dist/components/Carousel/carouselStore.d.ts.map +1 -0
  5. package/dist/components/Carousel/index.d.ts +61 -0
  6. package/dist/components/Carousel/index.d.ts.map +1 -0
  7. package/dist/components/Carousel/intersectionObserver.d.ts +7 -0
  8. package/dist/components/Carousel/intersectionObserver.d.ts.map +1 -0
  9. package/dist/components/Carousel/resizeObserver.d.ts +6 -0
  10. package/dist/components/Carousel/resizeObserver.d.ts.map +1 -0
  11. package/dist/components/Carousel/store.d.ts +7 -0
  12. package/dist/components/Carousel/store.d.ts.map +1 -0
  13. package/dist/components/Carousel/useCarouselScroller.d.ts +18 -0
  14. package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -0
  15. package/dist/components/Icon/index.d.ts +1 -1
  16. package/dist/components/Icon/index.d.ts.map +1 -1
  17. package/dist/index.cjs +2 -2
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.css +319 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/layered.css +319 -1
  25. package/dist/layered.css.map +1 -1
  26. package/package.json +5 -5
  27. package/src/__tests__/fixtures/legacy-v1-index.css +1836 -0
  28. package/src/__tests__/token-v1-compat.test.ts +118 -0
  29. package/src/__tests__/token-v1-remap.test.ts +133 -0
  30. package/src/components/Carousel/CarouselItem.tsx +56 -0
  31. package/src/components/Carousel/MIGRATION.md +117 -0
  32. package/src/components/Carousel/Migration.mdx +8 -0
  33. package/src/components/Carousel/__snapshots__/index.css.snap +274 -0
  34. package/src/components/Carousel/carouselStore.test.ts +38 -0
  35. package/src/components/Carousel/carouselStore.ts +48 -0
  36. package/src/components/Carousel/index.css +274 -0
  37. package/src/components/Carousel/index.story.tsx +169 -0
  38. package/src/components/Carousel/index.test.tsx +666 -0
  39. package/src/components/Carousel/index.tsx +278 -0
  40. package/src/components/Carousel/intersectionObserver.test.ts +68 -0
  41. package/src/components/Carousel/intersectionObserver.ts +58 -0
  42. package/src/components/Carousel/resizeObserver.test.ts +60 -0
  43. package/src/components/Carousel/resizeObserver.ts +35 -0
  44. package/src/components/Carousel/store.test.ts +25 -0
  45. package/src/components/Carousel/store.ts +27 -0
  46. package/src/components/Carousel/useCarouselScroller.ts +158 -0
  47. package/src/components/Icon/index.tsx +2 -1
  48. package/src/components/Modal/__snapshots__/index.css.snap +1 -1
  49. package/src/components/Modal/index.css +1 -1
  50. package/src/index.ts +12 -0
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import postcss, { type Rule } from 'postcss'
5
+
6
+ // Design Token 1.0互換モードの「見た目の非破壊」を検証する。
7
+ //
8
+ // 移行: 旧 `@charcoal-ui/react/dist/index.css` (v1, --charcoal-* を直接使用)
9
+ // → 新 コンポーネントCSS (v2, --charcoal-color-* を使用) + `_token_v1.css` remap
10
+ //
11
+ // 非破壊であるためには、各 (セレクタ, プロパティ) で新CSSが使う 1つの --charcoal-color-*
12
+ // が、旧CSSで使われていた「単一の」--charcoal-* に対応していなければならない。同じ新トークンが
13
+ // 箇所ごとに異なる旧トークンに対応するなら、v2 が複数の v1 トークンを統合したということで、
14
+ // remap がどんな値を選んでも一部の箇所で色が変わる = 見た目が壊れる。
15
+ // 例: --charcoal-color-container-neutral-default は Modal 背景 (旧 surface4 = 暗幕) と
16
+ // Switch (旧 text4) と Checkbox (旧 text3) に使われ、単一値では再現不可。
17
+ //
18
+ // 旧 index.css は charcoal の直前リリース (published v1) を fixtures に取り込んだもの。
19
+
20
+ const legacyPath = path.resolve(
21
+ import.meta.dirname,
22
+ 'fixtures/legacy-v1-index.css',
23
+ )
24
+ const componentsDir = path.resolve(import.meta.dirname, '../components')
25
+
26
+ async function getCssFiles(dir: string): Promise<string[]> {
27
+ const entries = await fs.readdir(dir, { withFileTypes: true })
28
+ const results = await Promise.all(
29
+ entries.map(async (entry) => {
30
+ const entryPath = path.join(dir, entry.name)
31
+ if (entry.isDirectory()) return getCssFiles(entryPath)
32
+ if (entry.isFile() && entry.name.endsWith('.css')) return [entryPath]
33
+ return []
34
+ }),
35
+ )
36
+ return results.flat()
37
+ }
38
+
39
+ /** 親 Rule を辿ってネストしたセレクタパスを組み立てる。 */
40
+ function selectorPath(rule: Rule): string {
41
+ const parts: string[] = []
42
+ let node: postcss.Container | postcss.Document | undefined = rule
43
+ while (node && node.type === 'rule') {
44
+ parts.unshift((node as Rule).selector.replace(/\s+/g, ' ').trim())
45
+ node = node.parent as postcss.Container | undefined
46
+ }
47
+ return parts.join(' >> ')
48
+ }
49
+
50
+ /** `${selectorPath} | ${prop}` -> 単一 var(--charcoal…) の参照トークン。 */
51
+ function declarationTokens(css: string): Map<string, string> {
52
+ const root = postcss.parse(css)
53
+ const map = new Map<string, string>()
54
+ root.walkDecls((decl) => {
55
+ const m = decl.value.match(/^var\(\s*(--charcoal-[a-z0-9-]+)\s*\)$/)
56
+ if (!m || decl.parent?.type !== 'rule') return
57
+ map.set(`${selectorPath(decl.parent as Rule)} | ${decl.prop}`, m[1])
58
+ })
59
+ return map
60
+ }
61
+
62
+ const legacy = declarationTokens(await fs.readFile(legacyPath, 'utf-8'))
63
+ const modern = new Map<string, string>()
64
+ for (const file of await getCssFiles(componentsDir)) {
65
+ for (const [key, token] of declarationTokens(
66
+ await fs.readFile(file, 'utf-8'),
67
+ )) {
68
+ modern.set(key, token)
69
+ }
70
+ }
71
+
72
+ // 新旧で一致する (セレクタ, プロパティ) について 新トークン -> {旧トークン…}
73
+ const correspondence = new Map<string, Map<string, string>>()
74
+ for (const [key, newToken] of modern) {
75
+ if (!legacy.has(key)) continue
76
+ if (!newToken.startsWith('--charcoal-color-')) continue
77
+ if (!correspondence.has(newToken)) correspondence.set(newToken, new Map())
78
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
79
+ correspondence.get(newToken)!.set(key, legacy.get(key)!)
80
+ }
81
+
82
+ describe('Design Token 1.0互換: 旧 index.css の見た目を壊さない', () => {
83
+ it('新トークンは旧の単一 --charcoal-* に対応する (v2 でのトークン統合による破壊がない)', () => {
84
+ const breaking = [...correspondence]
85
+ .map(([newToken, byKey]) => ({
86
+ newToken,
87
+ oldTokens: [...new Set(byKey.values())].sort(),
88
+ usages: [...byKey.entries()].map(([k, v]) => `${k} → ${v}`),
89
+ }))
90
+ .filter(({ oldTokens }) => oldTokens.length > 1)
91
+ .map(
92
+ ({ newToken, oldTokens, usages }, index) =>
93
+ `${index + 1}) ${newToken} は旧の {${oldTokens.join(', ')}} を破壊的に統合:\n - ${usages.join('\n - ')}`,
94
+ )
95
+ .join('\n')
96
+
97
+ expect(breaking).toMatchInlineSnapshot(`
98
+ "1) --charcoal-color-text-default は旧の {--charcoal-text1, --charcoal-text2} を破壊的に統合:
99
+ - .charcoal-button | color → --charcoal-text2
100
+ - .charcoal-checkbox__label_div | color → --charcoal-text2
101
+ - .charcoal-ui-dropdown-selector-text | color → --charcoal-text2
102
+ - .charcoal-field-label | color → --charcoal-text1
103
+ - .charcoal-modal-header-title | color → --charcoal-text1
104
+ - .charcoal-multi-select-label | color → --charcoal-text2
105
+ - .charcoal-radio__label_div | color → --charcoal-text2
106
+ - .charcoal-switch__label_div | color → --charcoal-text2
107
+ 2) --charcoal-color-border-default は旧の {--charcoal-text3, --charcoal-text4} を破壊的に統合:
108
+ - .charcoal-checkbox-input:not(:checked) | border-color → --charcoal-text4
109
+ - .charcoal-radio-input:not(:checked) | border-color → --charcoal-text3
110
+ 3) --charcoal-color-icon-tertiary-default は旧の {--charcoal-text3, --charcoal-text4} を破壊的に統合:
111
+ - .charcoal-hint-text-icon | color → --charcoal-text3
112
+ - .charcoal-loading-spinner | color → --charcoal-text4
113
+ 4) --charcoal-color-container-neutral-default は旧の {--charcoal-text3, --charcoal-text4} を破壊的に統合:
114
+ - .charcoal-multi-select-input[type='checkbox'] | background-color → --charcoal-text3
115
+ - .charcoal-switch-input | background-color → --charcoal-text4"
116
+ `)
117
+ })
118
+ })
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import postcss from 'postcss'
5
+
6
+ // Design Token 1.0互換モード (`@charcoal-ui/theme/css/_token_v1.css`) の remap を検証する。
7
+ //
8
+ // アプリは 1.0互換 (`_token_v1.css`) か 2.0 (`_variables_*.css`) のいずれかで --charcoal-color-*
9
+ // を供給し、コンポーネント実装CSS (`@charcoal-ui/react`) がそれを消費する。remap が
10
+ // 「壊さない・冗長でない・正しい」ことを次で担保する:
11
+ // 1. 非破壊: コンポーネントが使う --charcoal-color-* は全て remap 経由で解決できる。
12
+ // 2. 正しさ: --charcoal-color-* 同士の参照は全て定義済みで循環しない。重複定義がない。
13
+ // 3. 冗長でない: 2.0 API にも無く・内部参照も無く・コンポーネントも使わないトークンを持たない。
14
+
15
+ const cssDir = path.resolve(import.meta.dirname, '../../../theme/src/css')
16
+ const tokenV1Path = path.join(cssDir, '_token_v1.css')
17
+ const variables2Path = path.join(cssDir, '_variables_light.css')
18
+ const componentsDir = path.resolve(import.meta.dirname, '../components')
19
+
20
+ const COLOR_PREFIX = '--charcoal-color-'
21
+ const colorVarsIn = (value: string): string[] =>
22
+ [...value.matchAll(/var\(\s*(--charcoal-color-[a-z0-9-]+)/g)].map((m) => m[1])
23
+
24
+ async function getCssFiles(dir: string): Promise<string[]> {
25
+ const entries = await fs.readdir(dir, { withFileTypes: true })
26
+ const results = await Promise.all(
27
+ entries.map(async (entry) => {
28
+ const entryPath = path.join(dir, entry.name)
29
+ if (entry.isDirectory()) return getCssFiles(entryPath)
30
+ if (entry.isFile() && entry.name.endsWith('.css')) return [entryPath]
31
+ return []
32
+ }),
33
+ )
34
+ return results.flat()
35
+ }
36
+
37
+ /** --charcoal-color-* の定義 (prop -> value) を収集。重複も検出。 */
38
+ function parseColorDefinitions(css: string): {
39
+ defs: Map<string, string>
40
+ duplicates: string[]
41
+ } {
42
+ const defs = new Map<string, string>()
43
+ const duplicates: string[] = []
44
+ postcss.parse(css).walkDecls((decl) => {
45
+ if (!decl.prop.startsWith(COLOR_PREFIX)) return
46
+ if (defs.has(decl.prop)) duplicates.push(decl.prop)
47
+ defs.set(decl.prop, decl.value)
48
+ })
49
+ return { defs, duplicates }
50
+ }
51
+
52
+ /** 宣言値から使用中の --charcoal-color-* を収集。 */
53
+ function usedColorVars(css: string): Set<string> {
54
+ const used = new Set<string>()
55
+ postcss.parse(css).walkDecls((decl) => {
56
+ for (const v of colorVarsIn(decl.value)) used.add(v)
57
+ })
58
+ return used
59
+ }
60
+
61
+ /** --charcoal-color-* の var() チェインを解決。未定義参照・循環があれば失敗。 */
62
+ function resolve(
63
+ token: string,
64
+ defs: Map<string, string>,
65
+ seen = new Set<string>(),
66
+ ): { ok: true } | { ok: false; reason: string } {
67
+ if (!defs.has(token)) return { ok: false, reason: `undefined ${token}` }
68
+ if (seen.has(token)) return { ok: false, reason: `cycle at ${token}` }
69
+ seen.add(token)
70
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
71
+ for (const ref of colorVarsIn(defs.get(token)!)) {
72
+ const r = resolve(ref, defs, seen)
73
+ if (!r.ok) return r
74
+ }
75
+ return { ok: true } // --charcoal-color-* を含まない = 終端 (--charcoal-* かリテラル)
76
+ }
77
+
78
+ const { defs: tokenDefs, duplicates } = parseColorDefinitions(
79
+ await fs.readFile(tokenV1Path, 'utf-8'),
80
+ )
81
+ const variables2Defs = parseColorDefinitions(
82
+ await fs.readFile(variables2Path, 'utf-8'),
83
+ ).defs
84
+
85
+ const componentCssFiles = await getCssFiles(componentsDir)
86
+ const componentUsed = new Set<string>()
87
+ for (const file of componentCssFiles) {
88
+ for (const v of usedColorVars(await fs.readFile(file, 'utf-8')))
89
+ componentUsed.add(v)
90
+ }
91
+
92
+ describe('Design Token 1.0互換 (_token_v1.css) remap', () => {
93
+ it('重複定義がない', () => {
94
+ expect(duplicates).toEqual([])
95
+ })
96
+
97
+ it('全ての --charcoal-color-* が解決する (dangling / 循環がない)', () => {
98
+ const broken = [...tokenDefs.keys()]
99
+ .map((token) => ({ token, r: resolve(token, tokenDefs) }))
100
+ .filter(({ r }) => !r.ok)
101
+ .map(({ token, r }) => `${token}: ${(r as { reason: string }).reason}`)
102
+
103
+ expect(broken).toEqual([])
104
+ })
105
+
106
+ it('冗長トークンがない (2.0 API にも無く・内部参照も無く・コンポーネント未使用)', () => {
107
+ const internallyReferenced = new Set<string>()
108
+ for (const value of tokenDefs.values()) {
109
+ for (const ref of colorVarsIn(value)) internallyReferenced.add(ref)
110
+ }
111
+ const redundant = [...tokenDefs.keys()].filter(
112
+ (t) =>
113
+ !variables2Defs.has(t) &&
114
+ !internallyReferenced.has(t) &&
115
+ !componentUsed.has(t),
116
+ )
117
+
118
+ expect(redundant).toEqual([])
119
+ })
120
+
121
+ it.each(componentCssFiles)(
122
+ '%s: 使用する --charcoal-color-* は全て _token_v1.css 経由で解決する (非破壊)',
123
+ async (file) => {
124
+ const css = await fs.readFile(file, 'utf-8')
125
+ const unresolved = [...usedColorVars(css)]
126
+ .map((token) => ({ token, r: resolve(token, tokenDefs) }))
127
+ .filter(({ r }) => !r.ok)
128
+ .map(({ token }) => token)
129
+
130
+ expect(unresolved).toEqual([])
131
+ },
132
+ )
133
+ })
@@ -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,8 @@
1
+ import { Meta, Markdown } from '@storybook/addon-docs/blocks'
2
+ import migration from './MIGRATION.md?raw'
3
+
4
+ {/* 本文は MIGRATION.md を単一ソースとして ?raw で読み込み、ここで描画する */}
5
+
6
+ <Meta title="react/Carousel/Migration Guide" />
7
+
8
+ <Markdown>{migration}</Markdown>