@charcoal-ui/react 5.11.0-beta.1 → 5.11.0-beta.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/TextArea/index.d.ts +15 -0
- package/dist/components/TextArea/index.d.ts.map +1 -1
- package/dist/components/TextField/index.d.ts.map +1 -1
- package/dist/core/themeHelper.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +14 -0
- package/dist/index.d.ts +2 -1
- 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 +14 -0
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/components/FocusRing/__snapshots__/index.css.snap +6 -0
- package/src/components/FocusRing/index.css +10 -0
- package/src/components/FocusRing/index.story.tsx +33 -0
- package/src/components/TextArea/TextArea.story.tsx +88 -3
- package/src/components/TextArea/index.tsx +87 -37
- package/src/components/TextField/__snapshots__/index.css.snap +6 -0
- package/src/components/TextField/index.css +6 -0
- package/src/components/TextField/index.tsx +17 -15
- package/src/core/themeHelper.test.tsx +20 -0
- package/src/core/themeHelper.ts +26 -19
- package/src/index.ts +6 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
forwardRef,
|
|
6
6
|
useCallback,
|
|
7
7
|
useEffect,
|
|
8
|
+
useImperativeHandle,
|
|
8
9
|
useMemo,
|
|
9
10
|
useRef,
|
|
10
11
|
useState,
|
|
@@ -16,9 +17,24 @@ import { useId } from '@react-aria/utils'
|
|
|
16
17
|
import { AssistiveText } from '../TextField/AssistiveText'
|
|
17
18
|
import { useClassNames } from '../../_lib/useClassNames'
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* `TextArea` を `imperativeRef` から操作するためのハンドル
|
|
22
|
+
*/
|
|
23
|
+
export type TextAreaImperativeHandle = {
|
|
24
|
+
/**
|
|
25
|
+
* textarea の値を更新し、文字数や高さなどの内部状態を同期する
|
|
26
|
+
*/
|
|
27
|
+
setValue: (value: string) => void
|
|
28
|
+
/**
|
|
29
|
+
* textarea の現在の値をもとに、文字数や高さなどの内部状態を同期する
|
|
30
|
+
*/
|
|
31
|
+
sync: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export type TextAreaProps = {
|
|
20
35
|
value?: string
|
|
21
36
|
onChange?: (value: string) => void
|
|
37
|
+
imperativeRef?: React.Ref<TextAreaImperativeHandle>
|
|
22
38
|
|
|
23
39
|
showCount?: boolean
|
|
24
40
|
showLabel?: boolean
|
|
@@ -57,20 +73,29 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
|
57
73
|
invalid,
|
|
58
74
|
getCount = countCodePointsInString,
|
|
59
75
|
defaultValue,
|
|
76
|
+
imperativeRef,
|
|
60
77
|
...props
|
|
61
78
|
},
|
|
62
79
|
forwardRef,
|
|
63
80
|
) {
|
|
64
|
-
const
|
|
65
|
-
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
81
|
+
const [rows, setRows] = useState(initialRows)
|
|
66
82
|
const [count, setCount] = useState(
|
|
67
83
|
getCount(value ?? defaultValue?.toString() ?? ''),
|
|
68
84
|
)
|
|
69
|
-
|
|
85
|
+
|
|
86
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
87
|
+
const containerRef = useRef(null)
|
|
88
|
+
useFocusWithClick(containerRef, textareaRef)
|
|
89
|
+
const { visuallyHiddenProps } = useVisuallyHidden()
|
|
90
|
+
|
|
91
|
+
const isUncontrolled = value === undefined
|
|
70
92
|
const isEnableAutoHeight = useMemo(
|
|
71
93
|
() => autoHeight || (maxRows && maxRows >= 0),
|
|
72
94
|
[autoHeight, maxRows],
|
|
73
95
|
)
|
|
96
|
+
const classNames = useClassNames('charcoal-text-area-root', className)
|
|
97
|
+
const showAssistiveText =
|
|
98
|
+
assistiveText != null && assistiveText.length !== 0
|
|
74
99
|
|
|
75
100
|
const syncHeight = useCallback(
|
|
76
101
|
(textarea: HTMLTextAreaElement) => {
|
|
@@ -79,64 +104,89 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
|
79
104
|
const hasValidMaxRows = maxRows !== undefined && maxRows >= 1
|
|
80
105
|
const nextRows = initialRows <= currentRows ? currentRows : initialRows
|
|
81
106
|
|
|
82
|
-
if (!hasValidMaxRows
|
|
107
|
+
if (!hasValidMaxRows) {
|
|
83
108
|
setRows(nextRows)
|
|
84
109
|
return
|
|
85
110
|
}
|
|
86
111
|
|
|
87
|
-
setRows(nextRows
|
|
112
|
+
setRows(Math.min(nextRows, maxRows))
|
|
88
113
|
},
|
|
89
114
|
[initialRows, maxRows],
|
|
90
115
|
)
|
|
91
116
|
|
|
92
|
-
const
|
|
117
|
+
const syncTextAreaState = useCallback(
|
|
118
|
+
(textarea: HTMLTextAreaElement) => {
|
|
119
|
+
const count = getCount(textarea.value)
|
|
120
|
+
|
|
121
|
+
if (isUncontrolled) {
|
|
122
|
+
setCount(count)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isEnableAutoHeight) {
|
|
126
|
+
syncHeight(textarea)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return count
|
|
130
|
+
},
|
|
131
|
+
[getCount, isEnableAutoHeight, isUncontrolled, syncHeight],
|
|
132
|
+
)
|
|
133
|
+
|
|
93
134
|
const handleChange = useCallback(
|
|
94
135
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
95
|
-
const value = e.
|
|
136
|
+
const value = e.currentTarget.value
|
|
96
137
|
const count = getCount(value)
|
|
97
138
|
if (maxLength !== undefined && count > maxLength) {
|
|
98
139
|
return
|
|
99
140
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
if (isEnableAutoHeight && textareaRef.current !== null) {
|
|
104
|
-
syncHeight(textareaRef.current)
|
|
105
|
-
}
|
|
141
|
+
|
|
142
|
+
syncTextAreaState(e.currentTarget)
|
|
106
143
|
onChange?.(value)
|
|
107
144
|
},
|
|
108
|
-
[
|
|
109
|
-
isEnableAutoHeight,
|
|
110
|
-
getCount,
|
|
111
|
-
maxLength,
|
|
112
|
-
nonControlled,
|
|
113
|
-
onChange,
|
|
114
|
-
syncHeight,
|
|
115
|
-
],
|
|
145
|
+
[getCount, maxLength, onChange, syncTextAreaState],
|
|
116
146
|
)
|
|
117
147
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
useImperativeHandle(
|
|
149
|
+
imperativeRef,
|
|
150
|
+
() => ({
|
|
151
|
+
setValue: (value: string) => {
|
|
152
|
+
if (textareaRef.current === null) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
textareaRef.current.value = value
|
|
157
|
+
syncTextAreaState(textareaRef.current)
|
|
158
|
+
},
|
|
159
|
+
sync: () => {
|
|
160
|
+
if (textareaRef.current !== null) {
|
|
161
|
+
syncTextAreaState(textareaRef.current)
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
[syncTextAreaState],
|
|
166
|
+
)
|
|
131
167
|
|
|
132
168
|
const textAreaId = useId(props.id)
|
|
133
169
|
const describedbyId = useId()
|
|
134
170
|
const labelledbyId = useId()
|
|
135
171
|
|
|
136
|
-
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
// 制御コンポーネントの時の挙動
|
|
174
|
+
if (!isUncontrolled) {
|
|
175
|
+
setCount(getCount(value))
|
|
176
|
+
}
|
|
137
177
|
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
// autoHeight同期(valueが変更された時にsyncHeightしたい)
|
|
179
|
+
if (isEnableAutoHeight && textareaRef.current !== null) {
|
|
180
|
+
syncHeight(textareaRef.current)
|
|
181
|
+
}
|
|
182
|
+
}, [
|
|
183
|
+
isUncontrolled,
|
|
184
|
+
value,
|
|
185
|
+
getCount,
|
|
186
|
+
isEnableAutoHeight,
|
|
187
|
+
textareaRef,
|
|
188
|
+
syncHeight,
|
|
189
|
+
])
|
|
140
190
|
|
|
141
191
|
return (
|
|
142
192
|
<div className={classNames} aria-disabled={disabled}>
|
|
@@ -109,21 +109,23 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
|
|
109
109
|
ref={containerRef}
|
|
110
110
|
>
|
|
111
111
|
{prefix && <div className="charcoal-text-field-prefix">{prefix}</div>}
|
|
112
|
-
<input
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
112
|
+
<div className="charcoal-text-field-input-root">
|
|
113
|
+
<input
|
|
114
|
+
className="charcoal-text-field-input"
|
|
115
|
+
aria-describedby={showAssistiveText ? describedbyId : undefined}
|
|
116
|
+
aria-invalid={invalid}
|
|
117
|
+
aria-labelledby={labelledbyId}
|
|
118
|
+
id={inputId}
|
|
119
|
+
data-invalid={invalid === true}
|
|
120
|
+
maxLength={maxLength}
|
|
121
|
+
onChange={handleChange}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
ref={mergeRefs(forwardRef, inputRef)}
|
|
124
|
+
type={type}
|
|
125
|
+
value={value}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
127
129
|
{(suffix || showCount) && (
|
|
128
130
|
<div className="charcoal-text-field-suffix">
|
|
129
131
|
{suffix}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { useLocalStorage } from './themeHelper'
|
|
4
|
+
|
|
5
|
+
describe('themeHelper', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
localStorage.clear()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('does not enter an update loop when localStorage contains JSON', () => {
|
|
11
|
+
localStorage.setItem('theme', JSON.stringify({ mode: 'dark' }))
|
|
12
|
+
|
|
13
|
+
const { result } = renderHook(() =>
|
|
14
|
+
useLocalStorage<{ mode: string }>('theme'),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(result.current[0]).toEqual({ mode: 'dark' })
|
|
18
|
+
expect(result.current[2]).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
})
|
package/src/core/themeHelper.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
export const LOCAL_STORAGE_KEY = 'charcoal-theme'
|
|
4
4
|
export const DEFAULT_ROOT_ATTRIBUTE = 'theme'
|
|
@@ -29,6 +29,9 @@ export const themeSetter =
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// デフォルト引数で生成すると毎レンダー新しい関数になり、effect が再実行される
|
|
33
|
+
const defaultThemeSetter = themeSetter()
|
|
34
|
+
|
|
32
35
|
/**
|
|
33
36
|
* `<html data-theme="dark">` にマッチするセレクタを生成する
|
|
34
37
|
*/
|
|
@@ -51,7 +54,7 @@ export function prefersColorScheme<T extends 'light' | 'dark'>(theme: T) {
|
|
|
51
54
|
*/
|
|
52
55
|
export function useThemeSetter({
|
|
53
56
|
key = LOCAL_STORAGE_KEY,
|
|
54
|
-
setter =
|
|
57
|
+
setter = defaultThemeSetter,
|
|
55
58
|
}: { key?: string; setter?: (theme: string | undefined) => void } = {}) {
|
|
56
59
|
const [theme, , system] = useTheme(key)
|
|
57
60
|
|
|
@@ -93,29 +96,33 @@ export function useLocalStorage<T>(key: string, defaultValue?: () => T) {
|
|
|
93
96
|
const [state, setState] = useState<T>()
|
|
94
97
|
const defaultValueMemo = useMemo(() => defaultValue?.(), [defaultValue])
|
|
95
98
|
|
|
99
|
+
const fetch = useCallback(() => {
|
|
100
|
+
const raw = localStorage.getItem(key)
|
|
101
|
+
setState((raw !== null ? deserialize(raw) : null) ?? defaultValueMemo)
|
|
102
|
+
setReady(true)
|
|
103
|
+
}, [defaultValueMemo, key])
|
|
104
|
+
|
|
105
|
+
const handleStorage = useCallback(
|
|
106
|
+
(e: StorageEvent) => {
|
|
107
|
+
if (e.storageArea !== localStorage) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (e.key !== key) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
fetch()
|
|
114
|
+
},
|
|
115
|
+
[fetch, key],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// JSON は deserialize のたびに新しい object になるため、入力変更時だけ再取得する
|
|
96
119
|
useEffect(() => {
|
|
97
120
|
fetch()
|
|
98
121
|
window.addEventListener('storage', handleStorage)
|
|
99
122
|
return () => {
|
|
100
123
|
window.removeEventListener('storage', handleStorage)
|
|
101
124
|
}
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
const handleStorage = (e: StorageEvent) => {
|
|
105
|
-
if (e.storageArea !== localStorage) {
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
if (e.key !== key) {
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
fetch()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const fetch = () => {
|
|
115
|
-
const raw = localStorage.getItem(key)
|
|
116
|
-
setState((raw !== null ? deserialize(raw) : null) ?? defaultValueMemo)
|
|
117
|
-
setReady(true)
|
|
118
|
-
}
|
|
125
|
+
}, [fetch, handleStorage])
|
|
119
126
|
|
|
120
127
|
const set = (value: T | undefined) => {
|
|
121
128
|
if (value === undefined) {
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,11 @@ export {
|
|
|
38
38
|
default as TextField,
|
|
39
39
|
type TextFieldProps,
|
|
40
40
|
} from './components/TextField'
|
|
41
|
-
export {
|
|
41
|
+
export {
|
|
42
|
+
default as TextArea,
|
|
43
|
+
type TextAreaImperativeHandle,
|
|
44
|
+
type TextAreaProps,
|
|
45
|
+
} from './components/TextArea'
|
|
42
46
|
export { default as Icon, type IconProps } from './components/Icon'
|
|
43
47
|
export {
|
|
44
48
|
default as Modal,
|
|
@@ -91,3 +95,4 @@ export {
|
|
|
91
95
|
default as UnstablePagination,
|
|
92
96
|
type PaginationProps,
|
|
93
97
|
} from './components/Pagination'
|
|
98
|
+
import './components/FocusRing/index.css'
|