@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.
- 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/Icon/index.d.ts +1 -1
- package/dist/components/Icon/index.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +319 -1
- package/dist/index.d.ts +1 -0
- 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 +319 -1
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/fixtures/legacy-v1-index.css +1836 -0
- package/src/__tests__/token-v1-compat.test.ts +118 -0
- package/src/__tests__/token-v1-remap.test.ts +133 -0
- 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/Icon/index.tsx +2 -1
- package/src/components/Modal/__snapshots__/index.css.snap +1 -1
- package/src/components/Modal/index.css +1 -1
- 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)はコンポーネント側で付与する。
|