@charcoal-ui/react 4.2.0 → 4.3.0-beta.0

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 (56) hide show
  1. package/dist/_lib/index.d.ts.map +1 -1
  2. package/dist/components/Button/index.d.ts +2 -2
  3. package/dist/components/Button/index.d.ts.map +1 -1
  4. package/dist/components/Clickable/index.d.ts +2 -2
  5. package/dist/components/Clickable/index.d.ts.map +1 -1
  6. package/dist/components/DropdownSelector/ListItem/index.d.ts +2 -2
  7. package/dist/components/DropdownSelector/ListItem/index.d.ts.map +1 -1
  8. package/dist/components/DropdownSelector/MenuList/MenuListContext.d.ts +1 -1
  9. package/dist/components/DropdownSelector/MenuList/MenuListContext.d.ts.map +1 -1
  10. package/dist/components/DropdownSelector/Popover/index.d.ts +2 -2
  11. package/dist/components/DropdownSelector/Popover/index.d.ts.map +1 -1
  12. package/dist/components/IconButton/index.d.ts +2 -2
  13. package/dist/components/IconButton/index.d.ts.map +1 -1
  14. package/dist/components/Modal/useCustomModalOverlay.d.ts +1 -1
  15. package/dist/components/Modal/useCustomModalOverlay.d.ts.map +1 -1
  16. package/dist/components/Radio/RadioGroup/index.d.ts +1 -0
  17. package/dist/components/Radio/RadioGroup/index.d.ts.map +1 -1
  18. package/dist/components/TagItem/index.d.ts +2 -2
  19. package/dist/components/TagItem/index.d.ts.map +1 -1
  20. package/dist/components/TextField/index.d.ts.map +1 -1
  21. package/dist/components/TextField/useFocusWithClick.d.ts +1 -1
  22. package/dist/components/TextField/useFocusWithClick.d.ts.map +1 -1
  23. package/dist/core/SetThemeScript.d.ts +18 -0
  24. package/dist/core/SetThemeScript.d.ts.map +1 -0
  25. package/dist/core/themeHelper.d.ts +39 -0
  26. package/dist/core/themeHelper.d.ts.map +1 -0
  27. package/dist/index.cjs.js +424 -233
  28. package/dist/index.cjs.js.map +1 -1
  29. package/dist/index.css +1 -1
  30. package/dist/index.css.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.esm.js +315 -137
  34. package/dist/index.esm.js.map +1 -1
  35. package/dist/layered.css +1 -1
  36. package/dist/layered.css.map +1 -1
  37. package/package.json +11 -9
  38. package/src/_lib/index.ts +2 -0
  39. package/src/components/Button/index.tsx +7 -1
  40. package/src/components/Clickable/index.tsx +1 -1
  41. package/src/components/DropdownSelector/ListItem/index.tsx +1 -1
  42. package/src/components/DropdownSelector/MenuList/MenuListContext.ts +1 -1
  43. package/src/components/DropdownSelector/Popover/index.tsx +3 -2
  44. package/src/components/IconButton/index.tsx +30 -30
  45. package/src/components/Modal/__snapshots__/index.story.storyshot +0 -5
  46. package/src/components/Modal/useCustomModalOverlay.tsx +1 -1
  47. package/src/components/Radio/RadioGroup/index.tsx +1 -1
  48. package/src/components/SegmentedControl/__snapshots__/index.story.storyshot +0 -7
  49. package/src/components/TagItem/index.tsx +55 -55
  50. package/src/components/TextArea/index.css +1 -1
  51. package/src/components/TextField/index.tsx +2 -4
  52. package/src/components/TextField/text-field.test.tsx +72 -0
  53. package/src/components/TextField/useFocusWithClick.tsx +2 -2
  54. package/src/core/SetThemeScript.tsx +57 -0
  55. package/src/core/themeHelper.ts +180 -0
  56. package/src/index.ts +11 -0
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, memo, useMemo, ForwardedRef } from 'react'
1
+ import React, { forwardRef, memo, useMemo, ForwardedRef, type JSX } from 'react'
2
2
  import { useObjectRef } from '@react-aria/utils'
3
3
  import Icon from '../Icon'
4
4
  import { useClassNames } from '../../_lib/useClassNames'
@@ -24,66 +24,66 @@ export type TagItemProps<T extends React.ElementType = 'button'> = {
24
24
  component?: T
25
25
  } & Omit<React.ComponentPropsWithRef<T>, 'children'>
26
26
 
27
- const TagItem = forwardRef(function TagItemInner<T extends React.ElementType>(
28
- {
29
- component,
30
- label,
31
- translatedLabel,
32
- bgColor = '#7ACCB1',
33
- bgImage,
34
- size = 'M',
35
- status = 'default',
36
- ...props
37
- }: TagItemProps<T>,
38
- _ref: ForwardedRef<HTMLButtonElement>
39
- ) {
40
- const ref = useObjectRef(_ref)
27
+ const TagItem = forwardRef<HTMLButtonElement, TagItemProps>(
28
+ function TagItemInner<T extends React.ElementType>(
29
+ {
30
+ component,
31
+ label,
32
+ translatedLabel,
33
+ bgColor = '#7ACCB1',
34
+ bgImage,
35
+ size = 'M',
36
+ status = 'default',
37
+ ...props
38
+ }: TagItemProps<T>,
39
+ _ref: ForwardedRef<HTMLButtonElement>
40
+ ) {
41
+ const ref = useObjectRef(_ref)
41
42
 
42
- const hasTranslatedLabel =
43
- translatedLabel !== undefined && translatedLabel.length > 0
44
- const className = useClassNames(
45
- 'charcoal-tag-item',
46
- 'charcoal-tag-item__bg',
47
- props.className
48
- )
43
+ const hasTranslatedLabel =
44
+ translatedLabel !== undefined && translatedLabel.length > 0
45
+ const className = useClassNames(
46
+ 'charcoal-tag-item',
47
+ 'charcoal-tag-item__bg',
48
+ props.className
49
+ )
49
50
 
50
- const bgVariant =
51
- bgImage !== undefined && bgImage.length > 0 ? 'image' : 'color'
52
- const bg = bgVariant === 'color' ? bgColor : `url(${bgImage ?? ''})`
51
+ const bgVariant =
52
+ bgImage !== undefined && bgImage.length > 0 ? 'image' : 'color'
53
+ const bg = bgVariant === 'color' ? bgColor : `url(${bgImage ?? ''})`
53
54
 
54
- const Component = useMemo(() => component ?? 'button', [component])
55
+ const Component = useMemo(() => component ?? 'button', [component])
55
56
 
56
- return (
57
- <Component
58
- {...props}
59
- ref={ref}
60
- className={className}
61
- data-state={status}
62
- data-bg-variant={bgVariant}
63
- data-size={hasTranslatedLabel ? 'M' : size}
64
- style={{ '--charcoal-tag-item-bg': bg }}
65
- >
66
- <div
67
- className="charcoal-tag-item__label"
68
- data-has-translate={hasTranslatedLabel}
57
+ return (
58
+ <Component
59
+ {...props}
60
+ ref={ref}
61
+ className={className}
62
+ data-state={status}
63
+ data-bg-variant={bgVariant}
64
+ data-size={hasTranslatedLabel ? 'M' : size}
65
+ style={{ '--charcoal-tag-item-bg': bg }}
69
66
  >
70
- {hasTranslatedLabel && (
71
- <span className="charcoal-tag-item__label__translated">
72
- {translatedLabel}
73
- </span>
74
- )}
75
- <span
76
- className="charcoal-tag-item__label__text"
67
+ <div
68
+ className="charcoal-tag-item__label"
77
69
  data-has-translate={hasTranslatedLabel}
78
70
  >
79
- {label}
80
- </span>
81
- </div>
82
- {status === 'active' && <Icon name="16/Remove" />}
83
- </Component>
84
- )
85
- }) as <T extends React.ElementType = 'button'>(
86
- p: TagItemProps<T>
87
- ) => JSX.Element
71
+ {hasTranslatedLabel && (
72
+ <span className="charcoal-tag-item__label__translated">
73
+ {translatedLabel}
74
+ </span>
75
+ )}
76
+ <span
77
+ className="charcoal-tag-item__label__text"
78
+ data-has-translate={hasTranslatedLabel}
79
+ >
80
+ {label}
81
+ </span>
82
+ </div>
83
+ {status === 'active' && <Icon name="16/Remove" />}
84
+ </Component>
85
+ )
86
+ }
87
+ ) as <T extends React.ElementType = 'button'>(p: TagItemProps<T>) => JSX.Element
88
88
 
89
89
  export default memo(TagItem)
@@ -28,7 +28,7 @@
28
28
  box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
29
29
  }
30
30
 
31
- .charcoal-text-area-container:not(aria-disabled='true'):hover {
31
+ .charcoal-text-area-container:not([aria-disabled='true']):hover {
32
32
  background-color: var(--charcoal-surface3-hover);
33
33
  }
34
34
 
@@ -108,9 +108,7 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
108
108
  data-invalid={invalid === true}
109
109
  ref={containerRef}
110
110
  >
111
- {prefix !== null && (
112
- <div className="charcoal-text-field-prefix">{prefix}</div>
113
- )}
111
+ {prefix && <div className="charcoal-text-field-prefix">{prefix}</div>}
114
112
  <input
115
113
  className="charcoal-text-field-input"
116
114
  aria-describedby={showAssistiveText ? describedbyId : undefined}
@@ -126,7 +124,7 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
126
124
  value={value}
127
125
  {...props}
128
126
  />
129
- {(suffix !== null || showCount) && (
127
+ {(suffix || showCount) && (
130
128
  <div className="charcoal-text-field-suffix">
131
129
  {suffix}
132
130
  {showCount && (
@@ -0,0 +1,72 @@
1
+ import { render } from '@testing-library/react'
2
+ import TextField from '.'
3
+
4
+ import '@testing-library/jest-dom'
5
+
6
+ describe('TextField component', () => {
7
+ it('should not render prefix and suffix when not provided', () => {
8
+ const { container } = render(<TextField />)
9
+
10
+ // prefix and suffix elements should not be rendered
11
+ const prefixElement = container.querySelector('.charcoal-text-field-prefix')
12
+ const suffixElement = container.querySelector('.charcoal-text-field-suffix')
13
+
14
+ expect(prefixElement).toBeNull()
15
+ expect(suffixElement).toBeNull()
16
+ })
17
+
18
+ test.each([
19
+ [null, 'null'],
20
+ [undefined, 'undefined'],
21
+ ['', 'empty string'],
22
+ [false, 'boolean false'],
23
+ [0, 'zero'],
24
+ ])(
25
+ 'should not render prefix when value is falsy (%s: %s)',
26
+ (prefixValue, _desc) => {
27
+ const { container } = render(<TextField prefix={prefixValue} />)
28
+ const prefixElement = container.querySelector('.charcoal-text-prefix')
29
+ expect(prefixElement).toBeNull()
30
+ }
31
+ )
32
+
33
+ test.each([
34
+ [null, 'null'],
35
+ [undefined, 'undefined'],
36
+ ['', 'empty string'],
37
+ [false, 'boolean false'],
38
+ [0, 'zero'],
39
+ ])(
40
+ 'should not render suffix when value is falsy (%s: %s) and showCount is false',
41
+ (suffixValue, _desc) => {
42
+ const { container } = render(
43
+ <TextField suffix={suffixValue} showCount={false} />
44
+ )
45
+ const suffixElement = container.querySelector(
46
+ '.charcoal-text-field-suffix'
47
+ )
48
+ expect(suffixElement).toBeNull()
49
+ }
50
+ )
51
+
52
+ it('should render prefix and suffix when provided as truthy values', () => {
53
+ const prefixContent = 'Test Prefix'
54
+ const suffixContent = 'Test Suffix'
55
+ const { container, getByText } = render(
56
+ <TextField
57
+ prefix={<span>{prefixContent}</span>}
58
+ suffix={<span>{suffixContent}</span>}
59
+ />
60
+ )
61
+
62
+ const prefixElement = container.querySelector('.charcoal-text-field-prefix')
63
+ const suffixElement = container.querySelector('.charcoal-text-field-suffix')
64
+
65
+ expect(prefixElement).not.toBeNull()
66
+ expect(suffixElement).not.toBeNull()
67
+
68
+ // Verify text content
69
+ expect(getByText(prefixContent)).toBeInTheDocument()
70
+ expect(getByText(suffixContent)).toBeInTheDocument()
71
+ })
72
+ })
@@ -2,8 +2,8 @@ import { useEffect } from 'react'
2
2
  import * as React from 'react'
3
3
 
4
4
  export function useFocusWithClick(
5
- containerRef: React.RefObject<HTMLDivElement>,
6
- inputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
5
+ containerRef: React.RefObject<HTMLDivElement | null>,
6
+ inputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>
7
7
  ) {
8
8
  useEffect(() => {
9
9
  const el = containerRef.current
@@ -0,0 +1,57 @@
1
+ import {
2
+ assertKeyString,
3
+ DEFAULT_ROOT_ATTRIBUTE,
4
+ LOCAL_STORAGE_KEY,
5
+ } from './themeHelper'
6
+
7
+ interface Props {
8
+ localStorageKey: string
9
+ rootAttribute: string
10
+ }
11
+
12
+ /**
13
+ * 同期的にテーマをローカルストレージから取得してhtmlの属性に設定するコードを取得する
14
+ * @param props localStorageのキー、htmlのdataになる属性のキーを含むオブジェクト
15
+ * @returns ソースコードの文字列
16
+ */
17
+ export function makeSetThemeScriptCode({
18
+ localStorageKey = defaultProps.localStorageKey,
19
+ rootAttribute = defaultProps.rootAttribute,
20
+ }: Partial<Props> = defaultProps) {
21
+ assertKeyString(localStorageKey)
22
+ assertKeyString(rootAttribute)
23
+ return `'use strict';
24
+ (function () {
25
+ var localStorageKey = '${localStorageKey}'
26
+ var rootAttribute = '${rootAttribute}'
27
+ var currentTheme = localStorage.getItem(localStorageKey);
28
+ if (currentTheme) {
29
+ document.documentElement.dataset[rootAttribute] = currentTheme;
30
+ }
31
+ })();
32
+ `
33
+ }
34
+
35
+ /**
36
+ * 同期的にテーマをローカルストレージから取得してhtmlの属性に設定するスクリプトタグ
37
+ * @param props localStorageのキー、htmlのdataになる属性のキーを含むオブジェクト
38
+ * @returns
39
+ */
40
+ export function SetThemeScript({
41
+ localStorageKey = defaultProps.localStorageKey,
42
+ rootAttribute = defaultProps.rootAttribute,
43
+ }: Props) {
44
+ const src = makeSetThemeScriptCode({ localStorageKey, rootAttribute })
45
+ return (
46
+ <script
47
+ dangerouslySetInnerHTML={{
48
+ __html: src,
49
+ }}
50
+ />
51
+ )
52
+ }
53
+
54
+ const defaultProps: Props = {
55
+ localStorageKey: LOCAL_STORAGE_KEY,
56
+ rootAttribute: DEFAULT_ROOT_ATTRIBUTE,
57
+ }
@@ -0,0 +1,180 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+
3
+ export const LOCAL_STORAGE_KEY = 'charcoal-theme'
4
+ export const DEFAULT_ROOT_ATTRIBUTE = 'theme'
5
+
6
+ const keyStringRegExp = new RegExp(/^(\w|-)+$/)
7
+
8
+ /**
9
+ * 文字列が英数字_-のみで構成されているか検証する。不正な文字列ならエラーを投げる
10
+ * @param key 検証するキー
11
+ */
12
+ export function assertKeyString(key: string) {
13
+ if (!keyStringRegExp.test(key)) {
14
+ throw new Error(`Unexpected key :${key}, expect: /^(\\w|-)+$/`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * `<html data-theme="dark">` のような設定を行うデフォルトのセッター
20
+ */
21
+ export const themeSetter =
22
+ (attr: string = DEFAULT_ROOT_ATTRIBUTE) =>
23
+ (theme: string | undefined) => {
24
+ assertKeyString(attr)
25
+ if (theme !== undefined) {
26
+ document.documentElement.dataset[attr] = theme
27
+ } else {
28
+ delete document.documentElement.dataset[attr]
29
+ }
30
+ }
31
+
32
+ /**
33
+ * `<html data-theme="dark">` にマッチするセレクタを生成する
34
+ */
35
+ export function themeSelector<
36
+ T extends string,
37
+ S extends string = typeof DEFAULT_ROOT_ATTRIBUTE
38
+ >(theme: T, attr?: S) {
39
+ return `:root[data-${attr ?? DEFAULT_ROOT_ATTRIBUTE}='${theme}']` as const
40
+ }
41
+
42
+ /**
43
+ * prefers-color-scheme を利用する media クエリを生成する
44
+ */
45
+ export function prefersColorScheme<T extends 'light' | 'dark'>(theme: T) {
46
+ return `@media (prefers-color-scheme: ${theme})` as const
47
+ }
48
+
49
+ /**
50
+ * LocalStorageからテーマの情報を取得して、変化時にテーマをセットするhooks
51
+ */
52
+ export function useThemeSetter({
53
+ key = LOCAL_STORAGE_KEY,
54
+ setter = themeSetter(),
55
+ }: { key?: string; setter?: (theme: string | undefined) => void } = {}) {
56
+ const [theme, , system] = useTheme(key)
57
+
58
+ useEffect(() => {
59
+ if (theme === undefined) {
60
+ return
61
+ }
62
+ // prefers-color-scheme から値を取っている場合にはcssのみで処理したいのでアンセットする
63
+ setter(system ? undefined : theme)
64
+ }, [setter, system, theme])
65
+ }
66
+
67
+ /**
68
+ * 同期的にLocalStorageからテーマを取得するヘルパ
69
+ */
70
+ export function getThemeSync(key: string = LOCAL_STORAGE_KEY) {
71
+ const theme = localStorage.getItem(key)
72
+ return theme
73
+ }
74
+
75
+ /**
76
+ * LocalStorage, prefers-color-scheme からテーマの情報を取得して、現在のテーマを返すhooks
77
+ *
78
+ * `dark` `light` という名前だけは特別扱いされていて、prefers-color-schemeにマッチした場合に返ります
79
+ */
80
+ export const useTheme = (localStorageKey: string = LOCAL_STORAGE_KEY) => {
81
+ assertKeyString(localStorageKey)
82
+ const isDark = useMedia('(prefers-color-scheme: dark)')
83
+ const media = isDark !== undefined ? (isDark ? 'dark' : 'light') : undefined
84
+ const [local, setTheme, ready] =
85
+ useLocalStorage<typeof media>(localStorageKey)
86
+ const theme = !ready || media === undefined ? undefined : local ?? media
87
+ const system = local === undefined
88
+ return [theme, setTheme, system] as const
89
+ }
90
+
91
+ export function useLocalStorage<T>(key: string, defaultValue?: () => T) {
92
+ const [ready, setReady] = useState(false)
93
+ const [state, setState] = useState<T>()
94
+ const defaultValueMemo = useMemo(() => defaultValue?.(), [defaultValue])
95
+
96
+ useEffect(() => {
97
+ fetch()
98
+ window.addEventListener('storage', handleStorage)
99
+ return () => {
100
+ window.removeEventListener('storage', handleStorage)
101
+ }
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
+ }
119
+
120
+ const set = (value: T | undefined) => {
121
+ if (value === undefined) {
122
+ // undefinedがセットされる場合にはkeyごと削除
123
+ localStorage.removeItem(key)
124
+ } else {
125
+ const raw = serialize(value)
126
+ localStorage.setItem(key, raw)
127
+ }
128
+
129
+ // 同一ウィンドウではstorageイベントが発火しないので、手動で発火させる
130
+ const event = new StorageEvent('storage', {
131
+ bubbles: true,
132
+ cancelable: false,
133
+ key,
134
+ url: location.href,
135
+ storageArea: localStorage,
136
+ })
137
+ dispatchEvent(event)
138
+ }
139
+
140
+ return [state ?? defaultValueMemo, set, ready] as const
141
+ }
142
+
143
+ function deserialize<T>(raw: string): T {
144
+ try {
145
+ return JSON.parse(raw) as T
146
+ } catch {
147
+ // syntax error はすべて文字列として扱う
148
+ return raw as unknown as T
149
+ }
150
+ }
151
+
152
+ function serialize(value: unknown): string {
153
+ if (typeof value === 'string') {
154
+ return value
155
+ } else {
156
+ return JSON.stringify(value)
157
+ }
158
+ }
159
+
160
+ export function useMedia(query: string) {
161
+ const [match, setState] = useState<boolean>()
162
+
163
+ useEffect(() => {
164
+ const matcher = window.matchMedia(query)
165
+
166
+ const onChange = () => {
167
+ setState(matcher.matches)
168
+ }
169
+
170
+ matcher.addEventListener('change', onChange)
171
+
172
+ setState(matcher.matches)
173
+
174
+ return () => {
175
+ matcher.removeEventListener('change', onChange)
176
+ }
177
+ }, [query])
178
+
179
+ return match
180
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,17 @@ export {
4
4
  CharcoalProvider,
5
5
  type CharcoalProviderProps,
6
6
  } from './core/CharcoalProvider'
7
+ export { makeSetThemeScriptCode, SetThemeScript } from './core/SetThemeScript'
8
+ export {
9
+ getThemeSync,
10
+ themeSetter,
11
+ themeSelector,
12
+ prefersColorScheme,
13
+ useTheme,
14
+ useThemeSetter,
15
+ useLocalStorage,
16
+ useMedia,
17
+ } from './core/themeHelper'
7
18
  export { default as Button, type ButtonProps } from './components/Button'
8
19
  export {
9
20
  default as Clickable,