@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.
@@ -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 { visuallyHiddenProps } = useVisuallyHidden()
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
- const [rows, setRows] = useState(initialRows)
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 || maxRows <= initialRows) {
107
+ if (!hasValidMaxRows) {
83
108
  setRows(nextRows)
84
109
  return
85
110
  }
86
111
 
87
- setRows(nextRows <= maxRows ? nextRows : maxRows)
112
+ setRows(Math.min(nextRows, maxRows))
88
113
  },
89
114
  [initialRows, maxRows],
90
115
  )
91
116
 
92
- const nonControlled = value === undefined
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.target.value
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
- if (nonControlled) {
101
- setCount(count)
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
- useEffect(() => {
119
- setCount(getCount(value ?? defaultValue?.toString() ?? ''))
120
- }, [getCount, value, defaultValue])
121
-
122
- useEffect(() => {
123
- if (isEnableAutoHeight && textareaRef.current !== null) {
124
- syncHeight(textareaRef.current)
125
- }
126
- }, [isEnableAutoHeight, syncHeight])
127
-
128
- const containerRef = useRef(null)
129
-
130
- useFocusWithClick(containerRef, textareaRef)
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
- const classNames = useClassNames('charcoal-text-area-root', className)
172
+ useEffect(() => {
173
+ // 制御コンポーネントの時の挙動
174
+ if (!isUncontrolled) {
175
+ setCount(getCount(value))
176
+ }
137
177
 
138
- const showAssistiveText =
139
- assistiveText != null && assistiveText.length !== 0
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}>
@@ -51,6 +51,12 @@
51
51
  margin-left: 4px;
52
52
  }
53
53
 
54
+ .charcoal-text-field-input-root {
55
+ flex: 1;
56
+ min-width: 0;
57
+ overflow: hidden;
58
+ }
59
+
54
60
  .charcoal-text-field-input {
55
61
  border: none;
56
62
  box-sizing: border-box;
@@ -52,6 +52,12 @@
52
52
  margin-left: 4px;
53
53
  }
54
54
 
55
+ .charcoal-text-field-input-root {
56
+ flex: 1;
57
+ min-width: 0;
58
+ overflow: hidden;
59
+ }
60
+
55
61
  .charcoal-text-field-input {
56
62
  border: none;
57
63
  box-sizing: border-box;
@@ -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
- className="charcoal-text-field-input"
114
- aria-describedby={showAssistiveText ? describedbyId : undefined}
115
- aria-invalid={invalid}
116
- aria-labelledby={labelledbyId}
117
- id={inputId}
118
- data-invalid={invalid === true}
119
- maxLength={maxLength}
120
- onChange={handleChange}
121
- disabled={disabled}
122
- ref={mergeRefs(forwardRef, inputRef)}
123
- type={type}
124
- value={value}
125
- {...props}
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
+ })
@@ -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 = themeSetter(),
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 { default as TextArea, type TextAreaProps } from './components/TextArea'
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'