@charcoal-ui/react 5.4.4 → 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.4.4",
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.4.4",
58
- "@charcoal-ui/icons": "5.4.4",
59
- "@charcoal-ui/theme": "5.4.4",
60
- "@charcoal-ui/utils": "5.4.4"
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'
@@ -0,0 +1,98 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Meta, StoryObj } from '@storybook/react-webpack5'
3
+ import Pagination from '.'
4
+
5
+ type PaginationStoryArgs = {
6
+ page: number
7
+ pageCount: number
8
+ pageRangeDisplayed?: number
9
+ }
10
+
11
+ function PaginationWithState(args: PaginationStoryArgs) {
12
+ const [page, setPage] = useState(args.page)
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
+ )
50
+ }
51
+
52
+ export default {
53
+ title: 'react/Pagination',
54
+ component: Pagination,
55
+ parameters: {
56
+ layout: 'centered',
57
+ },
58
+ } satisfies Meta<typeof Pagination>
59
+
60
+ export const Default: StoryObj<typeof Pagination> = {
61
+ args: {
62
+ page: 5,
63
+ pageCount: 10,
64
+ },
65
+ render: (args) => <PaginationWithState {...args} />,
66
+ }
67
+
68
+ export const FirstPage: StoryObj<typeof Pagination> = {
69
+ args: {
70
+ page: 1,
71
+ pageCount: 10,
72
+ },
73
+ render: (args) => <PaginationWithState {...args} />,
74
+ }
75
+
76
+ export const LastPage: StoryObj<typeof Pagination> = {
77
+ args: {
78
+ page: 10,
79
+ pageCount: 10,
80
+ },
81
+ render: (args) => <PaginationWithState {...args} />,
82
+ }
83
+
84
+ export const ManyPages: StoryObj<typeof Pagination> = {
85
+ args: {
86
+ page: 50,
87
+ pageCount: 103,
88
+ },
89
+ render: (args) => <PaginationWithState {...args} />,
90
+ }
91
+
92
+ export const LinkPaginationStory: StoryObj<typeof Pagination> = {
93
+ args: {
94
+ page: 5,
95
+ pageCount: 10,
96
+ },
97
+ render: (args) => <LinkPaginationWithState {...args} />,
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
+ })
@@ -0,0 +1,71 @@
1
+ import { useDebugValue } from 'react'
2
+ import warning from 'warning'
3
+
4
+ export function usePaginationWindow(
5
+ page: number,
6
+ pageCount: number,
7
+ pageRangeDisplayed = 7,
8
+ ) {
9
+ 'use memo'
10
+ // ページャーのリンク生成例:
11
+ //
12
+ // < [ 1 ] [*2*] [ 3 ] [ 4 ] [ 5 ] [ 6 ] [ 7 ] >
13
+ //
14
+ // < [ 1 ] [ 2 ] [ 3 ] [*4*] [ 5 ] [ 6 ] [ 7 ] >
15
+ //
16
+ // < [ 1 ] ... [ 4 ] [*5*] [ 6 ] [ 7 ] [ 8 ] >
17
+ //
18
+ // < [ 1 ] ... [ 99 ] [*100*] [ 101 ] [ 102 ] [ 103 ] >
19
+ //
20
+ // < [ 1 ] ... [ 99 ] [ 100 ] [ 101 ] [ 102 ] [*103*]
21
+ //
22
+ // [*1*] [ 2 ] >
23
+ //
24
+ // デザインの意図: 前後移動時のカーソル移動を最小限にする。
25
+
26
+ if (process.env.NODE_ENV !== 'production') {
27
+ warning((page | 0) === page, `\`page\` must be integer (${page})`)
28
+ warning(
29
+ (pageCount | 0) === pageCount,
30
+ `\`pageCount\` must be integer (${pageCount})`,
31
+ )
32
+ warning(
33
+ (pageRangeDisplayed | 0) === pageRangeDisplayed,
34
+ `\`pageRangeDisplayed\` must be integer (${pageRangeDisplayed})`,
35
+ )
36
+ warning(pageRangeDisplayed > 2, `\`windowSize\` must be greater than 2`)
37
+ }
38
+
39
+ const visibleFirstPage = 1
40
+ const visibleLastPage = Math.min(
41
+ pageCount,
42
+ Math.max(page + Math.floor(pageRangeDisplayed / 2), pageRangeDisplayed),
43
+ )
44
+
45
+ const window = (() => {
46
+ if (visibleLastPage <= pageRangeDisplayed) {
47
+ // 表示範囲が1-7ページなら省略は無い。
48
+ return Array.from(
49
+ { length: 1 + visibleLastPage - visibleFirstPage },
50
+ (_, i) => visibleFirstPage + i,
51
+ )
52
+ } else {
53
+ const start = visibleLastPage - (pageRangeDisplayed - 1) + 2
54
+ // 表示範囲が1-7ページを超えるなら、
55
+ // - 1ページ目は固定で表示する
56
+ // - 2ページ目から現在のページの直前までは省略する
57
+ return [
58
+ visibleFirstPage,
59
+ '...' as const,
60
+ ...Array.from(
61
+ { length: 1 + visibleLastPage - start },
62
+ (_, i) => start + i,
63
+ ),
64
+ ]
65
+ }
66
+ })()
67
+
68
+ useDebugValue(window)
69
+
70
+ return window
71
+ }
@@ -0,0 +1,67 @@
1
+ .charcoal-pagination {
2
+ display: flex;
3
+ justify-content: center;
4
+ align-items: center;
5
+ }
6
+
7
+ .charcoal-pagination-button {
8
+ font-size: 1rem;
9
+ line-height: calc(1em + 8px);
10
+ text-decoration: none;
11
+ border: none;
12
+ outline: none;
13
+ touch-action: manipulation;
14
+ user-select: none;
15
+ transition:
16
+ box-shadow 0.2s ease 0s,
17
+ color 0.2s ease 0s,
18
+ background 0.2s ease 0s,
19
+ opacity 0.2s ease 0s;
20
+
21
+ display: flex;
22
+ justify-content: center;
23
+ align-items: center;
24
+ box-sizing: content-box;
25
+ min-width: 24px;
26
+ min-height: 24px;
27
+ padding: 8px;
28
+ cursor: pointer;
29
+ font-weight: bold;
30
+ /* HACK: Safari repaint fix */
31
+ /* stylelint-disable-next-line property-no-vendor-prefix */
32
+ -webkit-transform: translateZ(0);
33
+
34
+ background: transparent;
35
+ color: var(--charcoal-text3);
36
+ border-radius: 48px;
37
+ }
38
+
39
+ .charcoal-pagination-button[hidden] {
40
+ visibility: hidden;
41
+ display: block;
42
+ }
43
+
44
+ .charcoal-pagination-button:hover {
45
+ background: var(--charcoal-surface3);
46
+ color: var(--charcoal-text2);
47
+ }
48
+
49
+ .charcoal-pagination-button[aria-current] {
50
+ background-color: var(--charcoal-surface6);
51
+ color: var(--charcoal-text5);
52
+ }
53
+
54
+ .charcoal-pagination-button[aria-current]:hover {
55
+ background-color: var(--charcoal-surface6);
56
+ color: var(--charcoal-text5);
57
+ }
58
+
59
+ .charcoal-pagination-spacer {
60
+ cursor: default;
61
+ color: var(--charcoal-text3);
62
+ background: none;
63
+ }
64
+
65
+ .charcoal-pagination-spacer.charcoal-icon-button:disabled {
66
+ opacity: 1;
67
+ }
@@ -0,0 +1,153 @@
1
+ import './index.css'
2
+
3
+ import { usePaginationWindow } from './helper'
4
+ import { useClassNames } from '../../_lib/useClassNames'
5
+ import IconButton from '../IconButton'
6
+
7
+ interface CommonProps {
8
+ page: number
9
+ pageCount: number
10
+ pageRangeDisplayed?: number
11
+ }
12
+
13
+ type LinkComponentProps = {
14
+ href: string
15
+ className?: string
16
+ children?: React.ReactNode
17
+ }
18
+
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({
52
+ page,
53
+ pageCount,
54
+ pageRangeDisplayed,
55
+ onChange,
56
+ makeUrl,
57
+ component: LinkComponent = 'a',
58
+ className,
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)
67
+
68
+ const classNames = useClassNames('charcoal-pagination', className)
69
+
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 (
78
+ <IconButton
79
+ icon={isPrev ? '24/Prev' : '24/Next'}
80
+ size="M"
81
+ className="charcoal-pagination-button"
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
+ })}
93
+ />
94
+ )
95
+ }
96
+
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
+ }
118
+
119
+ if (typeof value !== 'number') return null
120
+
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
+ }
143
+
144
+ return (
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" />
151
+ </nav>
152
+ )
153
+ }