@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,278 @@
1
+ import './index.css'
2
+
3
+ import {
4
+ forwardRef,
5
+ memo,
6
+ useCallback,
7
+ useImperativeHandle,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ type ReactNode,
12
+ } from 'react'
13
+ import { mergeProps, useFocusRing, useKeyboard } from 'react-aria'
14
+ import { useClassNames } from '../../_lib/useClassNames'
15
+ import IconButton from '../IconButton'
16
+ import { CarouselItem as CarouselSlide } from './CarouselItem'
17
+ import {
18
+ createCarouselStore,
19
+ INITIAL_CAROUSEL_STATE,
20
+ type CarouselState,
21
+ } from './carouselStore'
22
+ import { useCarouselScroller } from './useCarouselScroller'
23
+
24
+ const getServerSnapshot = (): CarouselState => INITIAL_CAROUSEL_STATE
25
+
26
+ export type CarouselItem = {
27
+ id: string
28
+ children: ReactNode
29
+ }
30
+
31
+ export type ScrollAlign = 'left' | 'center' | 'right'
32
+
33
+ export type ScrollSnapType = 'none' | 'proximity' | 'mandatory'
34
+
35
+ export type ScrollSnapAlign = 'center' | 'start'
36
+
37
+ export type ScrollSnap = Readonly<{
38
+ type?: ScrollSnapType
39
+ align?: ScrollSnapAlign
40
+ }>
41
+
42
+ export type ScrollStepContext = Readonly<{
43
+ clientWidth: number
44
+ scrollWidth: number
45
+ scrollLeft: number
46
+ direction: 'prev' | 'next'
47
+ }>
48
+
49
+ export type ScrollStep = number | ((ctx: ScrollStepContext) => number)
50
+
51
+ // 命令的 API。ref 経由で初期位置へのリセットを公開する(react-sandbox 互換)。
52
+ export type CarouselHandlerRef = {
53
+ resetScroll: () => void
54
+ }
55
+
56
+ export type CarouselProps = Readonly<{
57
+ className?: string
58
+ hasGradient?: boolean
59
+ fullWidth?: boolean
60
+ navigationButtons?: boolean
61
+ indicator?: boolean
62
+ size?: 'S' | 'M'
63
+ // 進む量。number は clientWidth に対する比率、function は進む px を直接返す。
64
+ scrollStep?: ScrollStep
65
+ // スクロールスナップ。未指定時は size 基準(M=none / S=mandatory)、align=center。
66
+ // M の none は sandbox 同等に 0.75×表示幅ちょうど進む(スナップで着地を吸着しない)。
67
+ scrollSnap?: ScrollSnap
68
+ // react-sandbox 互換のコールバック。
69
+ onScroll?: (left: number) => void
70
+ onResize?: (width: number) => void
71
+ onScrollStateChange?: (canScroll: boolean) => void
72
+ defaultScroll?: { align?: ScrollAlign; offset?: number }
73
+ items: CarouselItem[]
74
+ }>
75
+
76
+ type Direction = 'prev' | 'next'
77
+
78
+ const DEFAULT_SCROLL_STEP = 0.75
79
+
80
+ const NAV_ICON = {
81
+ prev: '24/Prev',
82
+ next: '24/Next',
83
+ } as const
84
+
85
+ type NavigationButtonProps = Readonly<{
86
+ direction: Direction
87
+ canScroll: boolean
88
+ onScroll: (direction: Direction) => void
89
+ }>
90
+
91
+ const CarouselNavigationButton = ({
92
+ direction,
93
+ canScroll,
94
+ onScroll,
95
+ }: NavigationButtonProps) => {
96
+ const handleClick = useCallback(() => {
97
+ onScroll(direction)
98
+ }, [onScroll, direction])
99
+ return (
100
+ <IconButton
101
+ variant="Overlay"
102
+ size="S"
103
+ icon={NAV_ICON[direction]}
104
+ aria-label={direction === 'prev' ? 'Previous' : 'Next'}
105
+ disabled={!canScroll}
106
+ onClick={handleClick}
107
+ className="charcoal-carousel__navigation__item"
108
+ data-direction={direction}
109
+ data-hidden={!canScroll}
110
+ />
111
+ )
112
+ }
113
+
114
+ type IndicatorItemProps = Readonly<{
115
+ index: number
116
+ isActive: boolean
117
+ onSelect: (index: number) => void
118
+ }>
119
+
120
+ const CarouselIndicatorItem = ({
121
+ index,
122
+ isActive,
123
+ onSelect,
124
+ }: IndicatorItemProps) => {
125
+ const handleClick = useCallback(() => {
126
+ onSelect(index)
127
+ }, [onSelect, index])
128
+ return (
129
+ <button
130
+ className="charcoal-carousel__indicator__item"
131
+ data-active={isActive}
132
+ aria-current={isActive || undefined}
133
+ aria-label={`Go to slide ${index + 1}`}
134
+ onClick={handleClick}
135
+ />
136
+ )
137
+ }
138
+
139
+ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
140
+ {
141
+ size = 'M',
142
+ navigationButtons,
143
+ indicator,
144
+ hasGradient = false,
145
+ fullWidth = false,
146
+ scrollStep = DEFAULT_SCROLL_STEP,
147
+ scrollSnap,
148
+ onScroll,
149
+ onResize,
150
+ onScrollStateChange,
151
+ defaultScroll: { align = 'left', offset = 0 } = {},
152
+ ...props
153
+ }: CarouselProps,
154
+ ref,
155
+ ) {
156
+ const className = useClassNames('charcoal-carousel', props.className)
157
+ const showNavigationButtons = navigationButtons ?? size === 'M'
158
+ const showIndicator = indicator ?? size === 'S'
159
+ const snapType = scrollSnap?.type ?? (size === 'S' ? 'mandatory' : 'none')
160
+ const snapAlign = scrollSnap?.align ?? 'center'
161
+
162
+ const scrollerRef = useRef<HTMLDivElement>(null)
163
+ const [store] = useState(createCarouselStore)
164
+
165
+ const { scrollByStep, onItemResize, resetScroll } = useCarouselScroller(
166
+ scrollerRef,
167
+ store,
168
+ props.items.length,
169
+ { align, offset, scrollStep, onScroll, onResize, onScrollStateChange },
170
+ )
171
+
172
+ useImperativeHandle(ref, () => ({ resetScroll }), [resetScroll])
173
+
174
+ const { activeIndex, canPrev, canNext } = useSyncExternalStore(
175
+ store.subscribe,
176
+ store.getSnapshot,
177
+ getServerSnapshot,
178
+ )
179
+
180
+ const scrollToItem = useCallback(
181
+ (index: number) => store.dispatch({ type: 'requestScroll', index }),
182
+ [store],
183
+ )
184
+
185
+ // ←/→ でスクロール。コンテナにフォーカスがある時のみ。
186
+ const { keyboardProps } = useKeyboard({
187
+ onKeyDown: (e) => {
188
+ if (e.key === 'ArrowRight') {
189
+ e.preventDefault()
190
+ scrollByStep('next')
191
+ } else if (e.key === 'ArrowLeft') {
192
+ e.preventDefault()
193
+ scrollByStep('prev')
194
+ } else {
195
+ e.continuePropagation()
196
+ }
197
+ },
198
+ })
199
+
200
+ const {
201
+ focusProps: scrollerFocusProps,
202
+ isFocusVisible: scrollerFocusVisible,
203
+ } = useFocusRing()
204
+
205
+ return (
206
+ <div
207
+ className={className}
208
+ data-size={size}
209
+ data-has-gradient={hasGradient}
210
+ data-full-width={fullWidth}
211
+ data-indicator={showIndicator}
212
+ data-scroll-snap-type={snapType}
213
+ data-scroll-snap-align={snapAlign}
214
+ data-can-prev={canPrev}
215
+ data-can-next={canNext}
216
+ role="region"
217
+ aria-roledescription="carousel"
218
+ aria-label="Carousel"
219
+ >
220
+ <div className="charcoal-carousel__viewport">
221
+ <div
222
+ {...mergeProps(scrollerFocusProps, keyboardProps)}
223
+ ref={scrollerRef}
224
+ className="charcoal-carousel__scroller"
225
+ tabIndex={0}
226
+ data-focus-visible={scrollerFocusVisible || undefined}
227
+ >
228
+ {props.items.map((item, i) => (
229
+ <CarouselSlide
230
+ key={item.id}
231
+ index={i}
232
+ store={store}
233
+ onResize={onItemResize}
234
+ >
235
+ {item.children}
236
+ </CarouselSlide>
237
+ ))}
238
+ </div>
239
+
240
+ <div
241
+ className="charcoal-carousel__navigation"
242
+ data-visible={showNavigationButtons}
243
+ aria-hidden={!showNavigationButtons}
244
+ >
245
+ <CarouselNavigationButton
246
+ direction="prev"
247
+ canScroll={canPrev}
248
+ onScroll={scrollByStep}
249
+ />
250
+ <CarouselNavigationButton
251
+ direction="next"
252
+ canScroll={canNext}
253
+ onScroll={scrollByStep}
254
+ />
255
+ </div>
256
+ </div>
257
+
258
+ <div
259
+ className="charcoal-carousel__indicator"
260
+ data-visible={showIndicator}
261
+ aria-hidden={!showIndicator}
262
+ >
263
+ {props.items.map((item, i) => (
264
+ <CarouselIndicatorItem
265
+ key={item.id}
266
+ index={i}
267
+ isActive={i === activeIndex}
268
+ onSelect={scrollToItem}
269
+ />
270
+ ))}
271
+ </div>
272
+ </div>
273
+ )
274
+ })
275
+
276
+ Carousel.displayName = 'Carousel'
277
+
278
+ export default memo(Carousel)
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { observeCenter } from './intersectionObserver'
3
+
4
+ type MockIO = {
5
+ cb: IntersectionObserverCallback
6
+ observe: ReturnType<typeof vi.fn>
7
+ unobserve: ReturnType<typeof vi.fn>
8
+ disconnect: ReturnType<typeof vi.fn>
9
+ }
10
+
11
+ describe('observeCenter', () => {
12
+ const orig = globalThis.IntersectionObserver
13
+ afterEach(() => {
14
+ globalThis.IntersectionObserver = orig
15
+ })
16
+
17
+ it('IntersectionObserver 非対応なら no-op(throw しない)', () => {
18
+ globalThis.IntersectionObserver =
19
+ undefined as unknown as typeof globalThis.IntersectionObserver
20
+ const el = document.createElement('div')
21
+ expect(() => observeCenter(el, () => undefined)()).not.toThrow()
22
+ })
23
+
24
+ it('同一 root の el は observer を共有し、中央交差で対象の onEnter だけ呼ぶ', () => {
25
+ const instances: MockIO[] = []
26
+ globalThis.IntersectionObserver = vi.fn(
27
+ (cb: IntersectionObserverCallback) => {
28
+ const inst: MockIO = {
29
+ cb,
30
+ observe: vi.fn(),
31
+ unobserve: vi.fn(),
32
+ disconnect: vi.fn(),
33
+ }
34
+ instances.push(inst)
35
+ return inst
36
+ },
37
+ ) as unknown as typeof globalThis.IntersectionObserver
38
+
39
+ const root = document.createElement('div')
40
+ const a = document.createElement('div')
41
+ const b = document.createElement('div')
42
+ root.append(a, b)
43
+ const onA = vi.fn()
44
+ const onB = vi.fn()
45
+
46
+ const offA = observeCenter(a, onA)
47
+ const offB = observeCenter(b, onB)
48
+
49
+ // 同一 root(親)なので observer は1つだけ
50
+ expect(instances).toHaveLength(1)
51
+ expect(instances[0].observe).toHaveBeenCalledWith(a)
52
+ expect(instances[0].observe).toHaveBeenCalledWith(b)
53
+
54
+ // a が中央交差 → onA だけ
55
+ instances[0].cb(
56
+ [{ isIntersecting: true, target: a } as IntersectionObserverEntry],
57
+ instances[0] as unknown as IntersectionObserver,
58
+ )
59
+ expect(onA).toHaveBeenCalledTimes(1)
60
+ expect(onB).not.toHaveBeenCalled()
61
+
62
+ // 最後の el を外すと disconnect
63
+ offA()
64
+ expect(instances[0].disconnect).not.toHaveBeenCalled()
65
+ offB()
66
+ expect(instances[0].disconnect).toHaveBeenCalled()
67
+ })
68
+ })
@@ -0,0 +1,58 @@
1
+ // 中央検出(左右 50% を除外)。root は item の親(scroller)から導出する。
2
+ const CENTER_ROOT_MARGIN = '0px -50% 0px -50%'
3
+
4
+ type CenterEntry = {
5
+ observer: IntersectionObserver
6
+ callbacks: Map<Element, () => void>
7
+ }
8
+
9
+ // root(= el.parentElement)ごとに observer を1つ共有する。
10
+ const byRoot = new Map<Element, CenterEntry>()
11
+
12
+ /**
13
+ * `el` がその親(= scroller)の中央ラインに重なったら `onEnter` を呼ぶ。同一 root の
14
+ * IntersectionObserver を共有する singleton。返り値で監視解除(最後の1つで disconnect)。
15
+ * SSR / 非対応環境では no-op。
16
+ */
17
+ export function observeCenter(
18
+ el: HTMLElement,
19
+ onEnter: () => void,
20
+ ): () => void {
21
+ // window で SSR を弾く。polyfill が global に IntersectionObserver を生やしていても
22
+ // サーバー上では observe しない。
23
+ if (
24
+ typeof window === 'undefined' ||
25
+ typeof IntersectionObserver === 'undefined'
26
+ )
27
+ return () => undefined
28
+ const root = el.parentElement
29
+ if (!root) return () => undefined
30
+
31
+ let entry = byRoot.get(root)
32
+ if (!entry) {
33
+ const callbacks = new Map<Element, () => void>()
34
+ const observer = new IntersectionObserver(
35
+ (entries) => {
36
+ for (const e of entries) {
37
+ if (e.isIntersecting) callbacks.get(e.target)?.()
38
+ }
39
+ },
40
+ { root, rootMargin: CENTER_ROOT_MARGIN, threshold: 0 },
41
+ )
42
+ entry = { observer, callbacks }
43
+ byRoot.set(root, entry)
44
+ }
45
+ entry.callbacks.set(el, onEnter)
46
+ entry.observer.observe(el)
47
+
48
+ return () => {
49
+ const e = byRoot.get(root)
50
+ if (!e) return
51
+ e.callbacks.delete(el)
52
+ e.observer.unobserve(el)
53
+ if (e.callbacks.size === 0) {
54
+ e.observer.disconnect()
55
+ byRoot.delete(root)
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { observeResize } from './resizeObserver'
3
+
4
+ type MockRO = {
5
+ cb: ResizeObserverCallback
6
+ observe: ReturnType<typeof vi.fn>
7
+ unobserve: ReturnType<typeof vi.fn>
8
+ disconnect: ReturnType<typeof vi.fn>
9
+ }
10
+
11
+ describe('observeResize', () => {
12
+ const orig = globalThis.ResizeObserver
13
+ afterEach(() => {
14
+ globalThis.ResizeObserver = orig
15
+ })
16
+
17
+ it('ResizeObserver 非対応なら no-op(throw しない)', () => {
18
+ globalThis.ResizeObserver =
19
+ undefined as unknown as typeof globalThis.ResizeObserver
20
+ const el = document.createElement('div')
21
+ expect(() => observeResize(el, () => undefined)()).not.toThrow()
22
+ })
23
+
24
+ it('単一の observer を共有し、対象のサイズ変化で cb を呼ぶ', () => {
25
+ const instances: MockRO[] = []
26
+ globalThis.ResizeObserver = vi.fn((cb: ResizeObserverCallback) => {
27
+ const inst: MockRO = {
28
+ cb,
29
+ observe: vi.fn(),
30
+ unobserve: vi.fn(),
31
+ disconnect: vi.fn(),
32
+ }
33
+ instances.push(inst)
34
+ return inst
35
+ }) as unknown as typeof globalThis.ResizeObserver
36
+
37
+ const a = document.createElement('div')
38
+ const b = document.createElement('div')
39
+ const onA = vi.fn()
40
+ const onB = vi.fn()
41
+
42
+ const offA = observeResize(a, onA)
43
+ const offB = observeResize(b, onB)
44
+
45
+ // 単一 observer を共有
46
+ expect(instances).toHaveLength(1)
47
+
48
+ instances[0].cb(
49
+ [{ target: a } as ResizeObserverEntry],
50
+ instances[0] as unknown as ResizeObserver,
51
+ )
52
+ expect(onA).toHaveBeenCalledTimes(1)
53
+ expect(onB).not.toHaveBeenCalled()
54
+
55
+ offA()
56
+ expect(instances[0].disconnect).not.toHaveBeenCalled()
57
+ offB()
58
+ expect(instances[0].disconnect).toHaveBeenCalled()
59
+ })
60
+ })
@@ -0,0 +1,35 @@
1
+ // 全 el で単一の ResizeObserver を共有する singleton。
2
+ let observer: ResizeObserver | null = null
3
+ const callbacks = new Map<Element, () => void>()
4
+
5
+ function getObserver(): ResizeObserver | null {
6
+ // window で SSR を弾く。polyfill が global に ResizeObserver を生やしていても
7
+ // サーバー上では observe しない。
8
+ if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined')
9
+ return null
10
+ if (!observer) {
11
+ observer = new ResizeObserver((entries) => {
12
+ for (const e of entries) callbacks.get(e.target)?.()
13
+ })
14
+ }
15
+ return observer
16
+ }
17
+
18
+ /**
19
+ * `el` のサイズ変化で `cb` を呼ぶ。全要素で単一の ResizeObserver を共有する singleton。
20
+ * 返り値で監視解除(最後の1つで disconnect)。SSR / 非対応環境では no-op。
21
+ */
22
+ export function observeResize(el: Element, cb: () => void): () => void {
23
+ const obs = getObserver()
24
+ if (!obs) return () => undefined
25
+ callbacks.set(el, cb)
26
+ obs.observe(el)
27
+ return () => {
28
+ callbacks.delete(el)
29
+ obs.unobserve(el)
30
+ if (callbacks.size === 0) {
31
+ obs.disconnect()
32
+ observer = null
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createStore } from './store'
3
+
4
+ describe('createStore', () => {
5
+ it('reducer の結果を getSnapshot で返す', () => {
6
+ const s = createStore(
7
+ (n: number, a: { type: 'inc' }) => (a.type === 'inc' ? n + 1 : n),
8
+ 0,
9
+ )
10
+ expect(s.getSnapshot()).toBe(0)
11
+ s.dispatch({ type: 'inc' })
12
+ expect(s.getSnapshot()).toBe(1)
13
+ })
14
+
15
+ it('dispatch ごとに listener を呼び、unsubscribe で止まる', () => {
16
+ const s = createStore((n: number, _a: { type: 'inc' }) => n + 1, 0)
17
+ const fn = vi.fn()
18
+ const off = s.subscribe(fn)
19
+ s.dispatch({ type: 'inc' })
20
+ expect(fn).toHaveBeenCalledTimes(1)
21
+ off()
22
+ s.dispatch({ type: 'inc' })
23
+ expect(fn).toHaveBeenCalledTimes(1)
24
+ })
25
+ })
@@ -0,0 +1,27 @@
1
+ export type Store<State, Action> = Readonly<{
2
+ dispatch: (action: Action) => void
3
+ subscribe: (listener: () => void) => () => void
4
+ getSnapshot: () => State
5
+ }>
6
+
7
+ export function createStore<State, Action>(
8
+ reducer: (state: State, action: Action) => State,
9
+ initialState: State,
10
+ ): Store<State, Action> {
11
+ let state = initialState
12
+ const listeners = new Set<() => void>()
13
+
14
+ return {
15
+ dispatch(action) {
16
+ state = reducer(state, action)
17
+ for (const listener of listeners) listener()
18
+ },
19
+ subscribe(listener) {
20
+ listeners.add(listener)
21
+ return () => {
22
+ listeners.delete(listener)
23
+ }
24
+ },
25
+ getSnapshot: () => state,
26
+ }
27
+ }