@charcoal-ui/react 5.5.0-beta.0 → 5.5.0-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcoal-ui/react",
3
- "version": "5.5.0-beta.0",
3
+ "version": "5.5.0-beta.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -48,16 +48,16 @@
48
48
  "@react-aria/switch": "^3.6.0",
49
49
  "@react-aria/utils": "^3.23.0",
50
50
  "@react-aria/visually-hidden": "^3.8.8",
51
+ "@react-spring/web": "^10",
51
52
  "@react-stately/radio": "^3.10.2",
52
53
  "polished": "^4.1.4",
53
54
  "react-compiler-runtime": "1.0.0",
54
- "react-spring": "^9.0.0",
55
55
  "react-stately": "^3.26.0",
56
56
  "warning": "^4.0.3",
57
- "@charcoal-ui/foundation": "5.5.0-beta.0",
58
- "@charcoal-ui/icons": "5.5.0-beta.0",
59
- "@charcoal-ui/utils": "5.5.0-beta.0",
60
- "@charcoal-ui/theme": "5.5.0-beta.0"
57
+ "@charcoal-ui/foundation": "5.5.0-beta.1",
58
+ "@charcoal-ui/theme": "5.5.0-beta.1",
59
+ "@charcoal-ui/icons": "5.5.0-beta.1",
60
+ "@charcoal-ui/utils": "5.5.0-beta.1"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "react": ">=17.0.0"
@@ -2,7 +2,7 @@ import { useContext, forwardRef, memo } from 'react'
2
2
  import * as React from 'react'
3
3
  import { Overlay } from '@react-aria/overlays'
4
4
  import type { AriaDialogProps } from '@react-types/dialog'
5
- import { animated, useTransition, easings } from 'react-spring'
5
+ import { animated, useTransition, easings } from '@react-spring/web'
6
6
  import Button, { ButtonProps } from '../Button'
7
7
  import IconButton, { IconButtonProps } from '../IconButton'
8
8
  import { useObjectRef } from '@react-aria/utils'
@@ -1,10 +1,52 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import { Meta, StoryObj } from '@storybook/react-webpack5'
3
- import Pagination, { LinkPagination } from '.'
3
+ import Pagination from '.'
4
4
 
5
- function PaginationWithState(args: React.ComponentProps<typeof Pagination>) {
5
+ type PaginationStoryArgs = {
6
+ page: number
7
+ pageCount: number
8
+ pageRangeDisplayed?: number
9
+ }
10
+
11
+ function PaginationWithState(args: PaginationStoryArgs) {
6
12
  const [page, setPage] = useState(args.page)
7
- return <Pagination {...args} page={page} onChange={setPage} />
13
+ return (
14
+ <Pagination
15
+ page={page}
16
+ pageCount={args.pageCount}
17
+ pageRangeDisplayed={args.pageRangeDisplayed}
18
+ onChange={setPage}
19
+ />
20
+ )
21
+ }
22
+
23
+ function parsePageFromHash(fallback: number): number {
24
+ const match = window.location.hash.match(/^#page-(\d+)$/)
25
+ return match ? parseInt(match[1], 10) : fallback
26
+ }
27
+
28
+ function LinkPaginationWithState(args: PaginationStoryArgs) {
29
+ const [page, setPage] = useState(() => parsePageFromHash(args.page))
30
+
31
+ useEffect(() => {
32
+ const handleHashChange = () => setPage(parsePageFromHash(args.page))
33
+ window.addEventListener('hashchange', handleHashChange)
34
+ return () => window.removeEventListener('hashchange', handleHashChange)
35
+ }, [args.page])
36
+
37
+ return (
38
+ <div>
39
+ <p style={{ marginBottom: 8, fontSize: 14, color: '#666' }}>
40
+ Current page: {page}
41
+ </p>
42
+ <Pagination
43
+ page={page}
44
+ pageCount={args.pageCount}
45
+ pageRangeDisplayed={args.pageRangeDisplayed}
46
+ makeUrl={(p) => `#page-${p}`}
47
+ />
48
+ </div>
49
+ )
8
50
  }
9
51
 
10
52
  export default {
@@ -47,11 +89,10 @@ export const ManyPages: StoryObj<typeof Pagination> = {
47
89
  render: (args) => <PaginationWithState {...args} />,
48
90
  }
49
91
 
50
- export const LinkPaginationStory: StoryObj<typeof LinkPagination> = {
92
+ export const LinkPaginationStory: StoryObj<typeof Pagination> = {
51
93
  args: {
52
94
  page: 5,
53
95
  pageCount: 10,
54
- makeUrl: (p) => `#page-${p}`,
55
96
  },
56
- render: (args) => <LinkPagination {...args} />,
97
+ render: (args) => <LinkPaginationWithState {...args} />,
57
98
  }
@@ -0,0 +1,58 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { usePaginationWindow } from './helper'
3
+
4
+ describe('usePaginationWindow', () => {
5
+ describe('7 pages or less (no ellipsis)', () => {
6
+ it('returns [1,2,3,4,5,6,7] when page=1, pageCount=10', () => {
7
+ const { result } = renderHook(() => usePaginationWindow(1, 10))
8
+ expect(result.current).toEqual([1, 2, 3, 4, 5, 6, 7])
9
+ })
10
+
11
+ it('returns [1,2,3,4,5,6,7] when page=2, pageCount=10', () => {
12
+ const { result } = renderHook(() => usePaginationWindow(2, 10))
13
+ expect(result.current).toEqual([1, 2, 3, 4, 5, 6, 7])
14
+ })
15
+
16
+ it('returns [1,2,3,4,5,6,7] when page=4, pageCount=10', () => {
17
+ const { result } = renderHook(() => usePaginationWindow(4, 10))
18
+ expect(result.current).toEqual([1, 2, 3, 4, 5, 6, 7])
19
+ })
20
+ })
21
+
22
+ describe('with ellipsis', () => {
23
+ it('returns [1, ..., 4, 5, 6, 7, 8] when page=5, pageCount=100', () => {
24
+ const { result } = renderHook(() => usePaginationWindow(5, 100))
25
+ expect(result.current).toEqual([1, '...', 4, 5, 6, 7, 8])
26
+ })
27
+
28
+ it('returns [1, ..., 96, 97, 98, 99, 100] when page=100, pageCount=100', () => {
29
+ const { result } = renderHook(() => usePaginationWindow(100, 100))
30
+ expect(result.current).toEqual([1, '...', 96, 97, 98, 99, 100])
31
+ })
32
+ })
33
+
34
+ describe('with pageRangeDisplayed specified', () => {
35
+ it('shows first 5 pages when pageRangeDisplayed=5 (no ellipsis)', () => {
36
+ const { result } = renderHook(() => usePaginationWindow(2, 20, 5))
37
+ expect(result.current).toEqual([1, 2, 3, 4, 5])
38
+ })
39
+
40
+ it('shows ellipsis when pageRangeDisplayed=5', () => {
41
+ const { result } = renderHook(() => usePaginationWindow(5, 20, 5))
42
+ expect(result.current).toEqual([1, '...', 5, 6, 7])
43
+ })
44
+ })
45
+
46
+ describe('when arguments change', () => {
47
+ it('updates window when page changes', () => {
48
+ const { result, rerender } = renderHook(
49
+ ({ page, pageCount }) => usePaginationWindow(page, pageCount),
50
+ { initialProps: { page: 2, pageCount: 10 } },
51
+ )
52
+ expect(result.current).toEqual([1, 2, 3, 4, 5, 6, 7])
53
+
54
+ rerender({ page: 5, pageCount: 10 })
55
+ expect(result.current).toEqual([1, '...', 4, 5, 6, 7, 8])
56
+ })
57
+ })
58
+ })
@@ -1,11 +1,12 @@
1
- import { useDebugValue, useMemo } from 'react'
1
+ import { useDebugValue } from 'react'
2
2
  import warning from 'warning'
3
3
 
4
- export function usePagerWindow(
4
+ export function usePaginationWindow(
5
5
  page: number,
6
6
  pageCount: number,
7
7
  pageRangeDisplayed = 7,
8
8
  ) {
9
+ 'use memo'
9
10
  // ページャーのリンク生成例:
10
11
  //
11
12
  // < [ 1 ] [*2*] [ 3 ] [ 4 ] [ 5 ] [ 6 ] [ 7 ] >
@@ -35,13 +36,13 @@ export function usePagerWindow(
35
36
  warning(pageRangeDisplayed > 2, `\`windowSize\` must be greater than 2`)
36
37
  }
37
38
 
38
- const window = useMemo(() => {
39
- const visibleFirstPage = 1
40
- const visibleLastPage = Math.min(
41
- pageCount,
42
- Math.max(page + Math.floor(pageRangeDisplayed / 2), pageRangeDisplayed),
43
- )
39
+ const visibleFirstPage = 1
40
+ const visibleLastPage = Math.min(
41
+ pageCount,
42
+ Math.max(page + Math.floor(pageRangeDisplayed / 2), pageRangeDisplayed),
43
+ )
44
44
 
45
+ const window = (() => {
45
46
  if (visibleLastPage <= pageRangeDisplayed) {
46
47
  // 表示範囲が1-7ページなら省略は無い。
47
48
  return Array.from(
@@ -50,11 +51,11 @@ export function usePagerWindow(
50
51
  )
51
52
  } else {
52
53
  const start = visibleLastPage - (pageRangeDisplayed - 1) + 2
54
+ // 表示範囲が1-7ページを超えるなら、
55
+ // - 1ページ目は固定で表示する
56
+ // - 2ページ目から現在のページの直前までは省略する
53
57
  return [
54
- // 表示範囲が1-7ページを超えるなら、
55
- // - 1ページ目は固定で表示する
56
58
  visibleFirstPage,
57
- // - 2ページ目から現在のページの直前までは省略する
58
59
  '...' as const,
59
60
  ...Array.from(
60
61
  { length: 1 + visibleLastPage - start },
@@ -62,7 +63,7 @@ export function usePagerWindow(
62
63
  ),
63
64
  ]
64
65
  }
65
- }, [page, pageCount, pageRangeDisplayed])
66
+ })()
66
67
 
67
68
  useDebugValue(window)
68
69
 
@@ -1,158 +1,153 @@
1
1
  import './index.css'
2
2
 
3
- import { memo, useCallback } from 'react'
4
- import { usePagerWindow } from './helper'
3
+ import { usePaginationWindow } from './helper'
5
4
  import { useClassNames } from '../../_lib/useClassNames'
6
5
  import IconButton from '../IconButton'
7
6
 
8
- const Text = 'span'
9
-
10
7
  interface CommonProps {
11
8
  page: number
12
9
  pageCount: number
13
10
  pageRangeDisplayed?: number
14
11
  }
15
12
 
16
- export interface PaginationProps extends CommonProps {
17
- onChange(newPage: number): void
13
+ type LinkComponentProps = {
14
+ href: string
15
+ className?: string
16
+ children?: React.ReactNode
18
17
  }
19
18
 
20
- export default memo(function Pagination({
19
+ type NavProps = Omit<React.ComponentPropsWithoutRef<'nav'>, 'onChange'>
20
+
21
+ /**
22
+ * Pagination component. Use either `onChange` (button mode) or `makeUrl` (link mode).
23
+ *
24
+ * @example
25
+ * // Button mode - for client-side state
26
+ * <Pagination page={1} pageCount={10} onChange={setPage} />
27
+ *
28
+ * @example
29
+ * // Link mode - for server routing / static pages
30
+ * <Pagination page={1} pageCount={10} makeUrl={(p) => `?page=${p}`} />
31
+ *
32
+ * @example
33
+ * // Link mode with custom component (e.g. Next.js Link)
34
+ * <Pagination page={1} pageCount={10} makeUrl={(p) => `?page=${p}`} component={Link} />
35
+ */
36
+ export type PaginationProps = CommonProps &
37
+ NavProps &
38
+ (
39
+ | { onChange(newPage: number): void; makeUrl?: never; component?: never }
40
+ | {
41
+ makeUrl(page: number): string
42
+ onChange?: never
43
+ /**
44
+ * The component used for link elements. Receives `href`, `className`, and `children`.
45
+ * @default 'a'
46
+ */
47
+ component?: React.ElementType<LinkComponentProps>
48
+ }
49
+ )
50
+
51
+ export default function Pagination({
21
52
  page,
22
53
  pageCount,
23
54
  pageRangeDisplayed,
24
55
  onChange,
56
+ makeUrl,
57
+ component: LinkComponent = 'a',
25
58
  className,
26
- ...props
27
- }: PaginationProps & Omit<React.ComponentPropsWithoutRef<'nav'>, 'onChange'>) {
28
- const window = usePagerWindow(page, pageCount, pageRangeDisplayed)
29
- const makeClickHandler = useCallback(
30
- (value: number) => () => {
31
- onChange(value)
32
- },
33
- [onChange],
34
- )
59
+ ...navProps
60
+ }: PaginationProps) {
61
+ 'use memo'
62
+ const window = usePaginationWindow(page, pageCount, pageRangeDisplayed)
63
+ const isLinkMode = makeUrl !== undefined
64
+
65
+ // 'use memo' により React Compiler が自動でメモ化するため useCallback は不要
66
+ const makeClickHandler = (value: number) => () => onChange?.(value)
35
67
 
36
- const hasNext = page < pageCount
37
- const hasPrev = page > 1
38
68
  const classNames = useClassNames('charcoal-pagination', className)
39
69
 
40
- return (
41
- <nav {...props} className={classNames}>
42
- <IconButton
43
- icon="24/Prev"
44
- size="M"
45
- className="charcoal-pagination-button"
46
- data-no-background
47
- hidden={!hasPrev}
48
- disabled={!hasPrev}
49
- onClick={makeClickHandler(Math.max(1, page - 1))}
50
- />
51
- {window.map((p) =>
52
- p === '...' ? (
53
- <IconButton
54
- key={p}
55
- icon="24/Dot"
56
- size="M"
57
- disabled
58
- className="charcoal-pagination-button charcoal-pagination-spacer"
59
- aria-hidden
60
- />
61
- ) : p === page ? (
62
- // we remove the onClick but don't mark it as disabled to preserve keyboard focus
63
- // not doing so causes the focus ring to flicker in and out of existence
64
- <button
65
- key={p}
66
- type="button"
67
- className="charcoal-pagination-button"
68
- aria-current
69
- >
70
- <Text>{p}</Text>
71
- </button>
72
- ) : (
73
- <button
74
- key={p}
75
- type="button"
76
- className="charcoal-pagination-button"
77
- onClick={makeClickHandler(p)}
78
- >
79
- <Text>{p}</Text>
80
- </button>
81
- ),
82
- )}
70
+ const NavButton = ({ direction }: { direction: 'prev' | 'next' }) => {
71
+ const isPrev = direction === 'prev'
72
+ const targetPage = isPrev
73
+ ? Math.max(1, page - 1)
74
+ : Math.min(pageCount, page + 1)
75
+ const disabled = isPrev ? page <= 1 : page >= pageCount
76
+
77
+ return (
83
78
  <IconButton
84
- icon="24/Next"
79
+ icon={isPrev ? '24/Prev' : '24/Next'}
85
80
  size="M"
86
81
  className="charcoal-pagination-button"
87
- data-no-background
88
- hidden={!hasNext}
89
- disabled={!hasNext}
90
- onClick={makeClickHandler(Math.min(pageCount, page + 1))}
82
+ hidden={disabled}
83
+ {...(isLinkMode && makeUrl
84
+ ? {
85
+ component: LinkComponent as 'a',
86
+ href: makeUrl(targetPage),
87
+ 'aria-disabled': disabled,
88
+ }
89
+ : {
90
+ disabled,
91
+ onClick: makeClickHandler(targetPage),
92
+ })}
91
93
  />
92
- </nav>
93
- )
94
- })
94
+ )
95
+ }
95
96
 
96
- export interface LinkPaginationProps extends CommonProps {
97
- makeUrl(page: number): string
98
- }
97
+ const PageItem = ({ value }: { value: number | string }) => {
98
+ // 省略記号
99
+ if (value === '...') {
100
+ return (
101
+ <IconButton
102
+ icon="24/Dot"
103
+ size="M"
104
+ disabled
105
+ className="charcoal-pagination-button charcoal-pagination-spacer"
106
+ aria-hidden
107
+ />
108
+ )
109
+ }
110
+ // 現在ページ(クリック不可)
111
+ if (value === page) {
112
+ return (
113
+ <span className="charcoal-pagination-button" aria-current="page">
114
+ {value}
115
+ </span>
116
+ )
117
+ }
99
118
 
100
- export function LinkPagination({
101
- page,
102
- pageCount,
103
- pageRangeDisplayed,
104
- makeUrl,
105
- className,
106
- ...props
107
- }: LinkPaginationProps & React.ComponentPropsWithoutRef<'nav'>) {
108
- const window = usePagerWindow(page, pageCount, pageRangeDisplayed)
119
+ if (typeof value !== 'number') return null
109
120
 
110
- const hasNext = page < pageCount
111
- const hasPrev = page > 1
112
- const classNames = useClassNames('charcoal-pagination', className)
121
+ // リンクモード: ページへのリンク
122
+ if (isLinkMode && makeUrl) {
123
+ return (
124
+ <LinkComponent
125
+ href={makeUrl(value)}
126
+ className="charcoal-pagination-button"
127
+ >
128
+ {value}
129
+ </LinkComponent>
130
+ )
131
+ }
132
+ // ボタンモード: クリックでページ遷移
133
+ return (
134
+ <button
135
+ type="button"
136
+ className="charcoal-pagination-button"
137
+ onClick={makeClickHandler(value)}
138
+ >
139
+ {value}
140
+ </button>
141
+ )
142
+ }
113
143
 
114
144
  return (
115
- <nav {...props} className={classNames}>
116
- <IconButton
117
- icon="24/Prev"
118
- size="M"
119
- component="a"
120
- href={makeUrl(Math.max(1, page - 1))}
121
- className="charcoal-pagination-button"
122
- data-no-background
123
- hidden={!hasPrev}
124
- aria-disabled={!hasPrev}
125
- />
126
- {window.map((p) =>
127
- p === '...' ? (
128
- <IconButton
129
- key={p}
130
- icon="24/Dot"
131
- size="M"
132
- disabled
133
- className="charcoal-pagination-button charcoal-pagination-spacer"
134
- aria-hidden
135
- />
136
- ) : p === page ? (
137
- <span key={p} className="charcoal-pagination-button" aria-current>
138
- <Text>{p}</Text>
139
- </span>
140
- ) : (
141
- <a key={p} href={makeUrl(p)} className="charcoal-pagination-button">
142
- <Text>{p}</Text>
143
- </a>
144
- ),
145
- )}
146
- <IconButton
147
- icon="24/Next"
148
- size="M"
149
- component="a"
150
- href={makeUrl(Math.min(pageCount, page + 1))}
151
- className="charcoal-pagination-button"
152
- data-no-background
153
- hidden={!hasNext}
154
- aria-disabled={!hasNext}
155
- />
145
+ <nav {...navProps} className={classNames} aria-label="Pagination">
146
+ <NavButton direction="prev" />
147
+ {window.map((p) => (
148
+ <PageItem key={p} value={p} />
149
+ ))}
150
+ <NavButton direction="next" />
156
151
  </nav>
157
152
  )
158
153
  }