@geckou/ui-react 0.1.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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/package.json +52 -0
  4. package/src/components/BasicButton.tsx +81 -0
  5. package/src/components/CheckBox.tsx +87 -0
  6. package/src/components/CheckBoxes.tsx +93 -0
  7. package/src/components/CheckButton.tsx +66 -0
  8. package/src/components/DatePicker.tsx +154 -0
  9. package/src/components/DateRangePicker.tsx +59 -0
  10. package/src/components/DateSelector.tsx +162 -0
  11. package/src/components/DropdownUi.tsx +126 -0
  12. package/src/components/ErrorMessage.tsx +30 -0
  13. package/src/components/FileInput.tsx +93 -0
  14. package/src/components/InputBox.tsx +118 -0
  15. package/src/components/InputGroup.tsx +13 -0
  16. package/src/components/LabeledCheckbox.tsx +61 -0
  17. package/src/components/LabeledFieldset.tsx +18 -0
  18. package/src/components/LoadingSpinner.tsx +23 -0
  19. package/src/components/ModalBox.tsx +109 -0
  20. package/src/components/PopupBox.tsx +70 -0
  21. package/src/components/RadioButtons.tsx +94 -0
  22. package/src/components/SearchableSelectBox.tsx +108 -0
  23. package/src/components/SelectBox.tsx +152 -0
  24. package/src/components/SlideDownUi.tsx +123 -0
  25. package/src/components/TabUI.tsx +112 -0
  26. package/src/components/TextArea.tsx +102 -0
  27. package/src/components/TextBox.tsx +120 -0
  28. package/src/components/ToggleButton.tsx +102 -0
  29. package/src/components/icons/BackupIcon.tsx +16 -0
  30. package/src/components/icons/CalendarIcon.tsx +15 -0
  31. package/src/components/icons/CheckIcon.tsx +7 -0
  32. package/src/components/icons/CloseIcon.tsx +16 -0
  33. package/src/components/icons/KeyboardArrowDownIcon.tsx +16 -0
  34. package/src/constants/index.ts +2 -0
  35. package/src/hooks/useFormValidation.ts +69 -0
  36. package/src/index.ts +66 -0
  37. package/src/styles/tokens.css +61 -0
  38. package/src/types/index.ts +23 -0
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { useEffect, useId, useRef } from 'react'
5
+
6
+ type Props = {
7
+ isShown: boolean
8
+ size?: 'small' | 'medium' | 'large'
9
+ onClose: () => void
10
+ header?: ReactNode
11
+ footer?: ReactNode
12
+ children: ReactNode
13
+ // header が無い場合のダイアログのアクセシブル名
14
+ ariaLabel?: string
15
+ }
16
+
17
+ const MAX_WIDTH_CLASSES = {
18
+ small: 'max-w-[var(--mobile-lower-width,430px)]',
19
+ medium: 'max-w-[var(--desktop-lower-width,1025px)]',
20
+ large: 'max-w-[var(--contents-max-width,1440px)]',
21
+ }
22
+
23
+ function CloseIcon() {
24
+ return (
25
+ <svg
26
+ xmlns="http://www.w3.org/2000/svg"
27
+ viewBox="0 -960 960 960"
28
+ fill="currentColor"
29
+ >
30
+ <path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z" />
31
+ </svg>
32
+ )
33
+ }
34
+
35
+ export function ModalBox({
36
+ isShown,
37
+ size = 'medium',
38
+ onClose,
39
+ header,
40
+ footer,
41
+ children,
42
+ ariaLabel,
43
+ }: Props) {
44
+ const headerId = useId()
45
+ const dialogRef = useRef<HTMLDivElement>(null)
46
+
47
+ useEffect(() => {
48
+ if (isShown) document.body.style.overflow = 'hidden'
49
+ else document.body.style.overflow = ''
50
+
51
+ return () => {
52
+ document.body.style.overflow = ''
53
+ }
54
+ }, [isShown])
55
+
56
+ useEffect(() => {
57
+ if (isShown) dialogRef.current?.focus()
58
+ }, [isShown])
59
+
60
+ return (
61
+ <div
62
+ className={`fixed top-0 left-0 z-50 flex h-dvh w-dvw cursor-pointer items-center justify-center overflow-hidden bg-[#33333380] p-[var(--sp-larger,3rem)] backdrop-blur-sm transition-opacity duration-100 max-md:px-[var(--sp-large,1.5rem)] ${
63
+ isShown
64
+ ? 'pointer-events-auto opacity-100'
65
+ : 'pointer-events-none opacity-0'
66
+ }`}
67
+ aria-hidden={!isShown}
68
+ inert={!isShown}
69
+ onClick={(event) => {
70
+ if (event.target === event.currentTarget) onClose()
71
+ }}
72
+ >
73
+ <div
74
+ ref={dialogRef}
75
+ role="dialog"
76
+ aria-modal="true"
77
+ aria-labelledby={header ? headerId : undefined}
78
+ aria-label={header ? undefined : (ariaLabel ?? 'ダイアログ')}
79
+ tabIndex={-1}
80
+ className={`relative flex max-h-full w-full cursor-auto flex-col rounded-[var(--radius-small,0.1875rem)] bg-white drop-shadow-[0_0_6px_#33333355] ${MAX_WIDTH_CLASSES[size]}`}
81
+ >
82
+ {header && (
83
+ <header
84
+ id={headerId}
85
+ className="border-b border-[#eee] px-[var(--sp-large,1.5rem)] py-[var(--sp-medium,0.75rem)] max-md:p-[var(--sp-medium,0.75rem)] [&>h2]:font-bold [&>h2]:text-[var(--fs-large,0.875rem)]"
86
+ >
87
+ {header}
88
+ </header>
89
+ )}
90
+ <div className="flex-auto overflow-auto p-[var(--sp-large,1.5rem)] max-md:p-[var(--sp-medium,0.75rem)]">
91
+ {children}
92
+ </div>
93
+ {footer && (
94
+ <footer className="flex justify-center gap-[var(--sp-large,1.5rem)] border-t border-[#eee] px-[var(--sp-large,1.5rem)] py-[var(--sp-medium,0.75rem)] max-md:gap-[var(--sp-medium,0.75rem)] max-md:p-[var(--sp-medium,0.75rem)] max-md:[&>*]:flex-auto">
95
+ {footer}
96
+ </footer>
97
+ )}
98
+ <button
99
+ type="button"
100
+ aria-label="閉じる"
101
+ className="absolute bottom-full left-full size-[var(--icon-medium,1.125rem)] cursor-pointer leading-none text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white [&>*]:size-full"
102
+ onClick={onClose}
103
+ >
104
+ <CloseIcon />
105
+ </button>
106
+ </div>
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode, Ref } from 'react'
4
+ import { useEffect, useImperativeHandle, useRef, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { COLOR } from '../constants'
7
+
8
+ export type PopupBoxHandle = {
9
+ showPopup: () => void
10
+ }
11
+
12
+ type Props = {
13
+ position?: {
14
+ x: 'left' | 'right' | 'center'
15
+ y: 'top' | 'bottom' | 'center'
16
+ }
17
+ ref?: Ref<PopupBoxHandle>
18
+ children: ReactNode
19
+ }
20
+
21
+ const X_CLASSES = {
22
+ left: 'left-[max(calc((100vw-64rem)/2),1rem)]',
23
+ right: 'right-[max(calc((100vw-64rem)/2),1rem)]',
24
+ center: 'inset-0 m-auto',
25
+ }
26
+
27
+ const Y_CLASSES = {
28
+ top: 'top-14',
29
+ bottom: 'bottom-4',
30
+ center: 'inset-0 m-auto',
31
+ }
32
+
33
+ export function PopupBox({
34
+ position = { x: 'right', y: 'top' },
35
+ ref,
36
+ children,
37
+ }: Props) {
38
+ const [isShown, setIsShown] = useState(false)
39
+ const [isMounted, setIsMounted] = useState(false)
40
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
41
+
42
+ useEffect(() => {
43
+ setIsMounted(true)
44
+
45
+ return () => {
46
+ if (timerRef.current) clearTimeout(timerRef.current)
47
+ }
48
+ }, [])
49
+
50
+ useImperativeHandle(ref, () => ({
51
+ showPopup: () => {
52
+ setIsShown(true)
53
+
54
+ if (timerRef.current) clearTimeout(timerRef.current)
55
+ timerRef.current = setTimeout(() => setIsShown(false), 3000)
56
+ },
57
+ }))
58
+
59
+ if (!isMounted) return null
60
+
61
+ return createPortal(
62
+ <div
63
+ style={{ borderColor: COLOR.blue }}
64
+ className={`pointer-events-none fixed z-50 h-max w-max max-w-40 rounded-[var(--radius-small,0.1875rem)] border bg-white p-[var(--sp-medium,0.75rem)] transition-[transform,opacity] duration-300 ${X_CLASSES[position.x]} ${Y_CLASSES[position.y]} ${isShown ? 'opacity-100' : 'opacity-0'}`}
65
+ >
66
+ {children}
67
+ </div>,
68
+ document.body,
69
+ )
70
+ }
@@ -0,0 +1,94 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties } from 'react'
4
+ import type {
5
+ Option,
6
+ RadioButtonStyleForEachStatus,
7
+ SelectValue,
8
+ } from '../types'
9
+ import { useId } from 'react'
10
+ import { COLOR } from '../constants'
11
+
12
+ type Props = {
13
+ value: SelectValue
14
+ onChange?: (newValue: SelectValue) => void
15
+ options: Option[]
16
+ name?: string
17
+ isDisabled?: boolean
18
+ isRequired?: boolean
19
+ cssStyle?: RadioButtonStyleForEachStatus
20
+ isDisableAnimation?: boolean
21
+ }
22
+
23
+ export function RadioButtons({
24
+ value,
25
+ onChange,
26
+ options,
27
+ name,
28
+ isDisabled,
29
+ isRequired,
30
+ cssStyle,
31
+ isDisableAnimation,
32
+ }: Props) {
33
+ const selectedValue = value ?? ''
34
+ // Vue 版(@geckou/ui-vue)は option ごとに別の name を振っていたため、
35
+ // ラジオグループとして機能しなかった(フォーム送信・キーボード操作)
36
+ const generatedName = useId()
37
+ const groupName = name ?? generatedName
38
+ const baseStyle = isDisabled ? cssStyle?.disabled : cssStyle?.default
39
+
40
+ const currentCssStyle = {
41
+ textColor: isDisabled ? COLOR.darkGray : COLOR.black,
42
+ backgroundColor: isDisabled ? COLOR.lightGray : COLOR.white,
43
+ border: {
44
+ color: isDisabled ? COLOR.darkGray : COLOR.blue,
45
+ size: '1px',
46
+ },
47
+ ...(baseStyle ?? {}),
48
+ }
49
+
50
+ const style = {
51
+ '--text-color': currentCssStyle.textColor,
52
+ '--border-color': currentCssStyle.border?.color,
53
+ '--border-size': currentCssStyle.border?.size,
54
+ '--background-color': currentCssStyle.backgroundColor,
55
+ '--duration': isDisableAnimation ? '0s' : '.3s',
56
+ } as CSSProperties
57
+
58
+ return (
59
+ <div className="flex flex-wrap items-center gap-4">
60
+ <style>
61
+ {
62
+ '@keyframes uiRadioPop{0%{scale:1}10%{scale:.8}50%{scale:1.2}100%{scale:1}}'
63
+ }
64
+ </style>
65
+ {options.map((option) => {
66
+ const isChecked = option.value === selectedValue
67
+
68
+ return (
69
+ <label
70
+ key={option.value}
71
+ style={style}
72
+ className={`relative grid cursor-pointer grid-cols-[auto_1fr] items-center gap-2 before:inline-block before:aspect-square before:w-4 before:rounded-full before:transition-all before:duration-(--duration) before:ease-linear before:content-[''] has-[input:disabled]:cursor-not-allowed has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-(--border-color) ${
73
+ isChecked
74
+ ? 'text-(--text-color) before:animate-[uiRadioPop_var(--duration)_ease-out] before:bg-(--border-color) before:shadow-[0_0_0_2px_var(--background-color)_inset,0_0_0_1px_var(--border-color)]'
75
+ : 'text-(--border-color) before:bg-(--background-color) before:shadow-[0_0_0_1px_var(--border-color)_inset]'
76
+ }`}
77
+ >
78
+ <input
79
+ type="radio"
80
+ name={groupName}
81
+ value={option.value}
82
+ disabled={isDisabled}
83
+ required={isRequired}
84
+ checked={isChecked}
85
+ onChange={() => onChange?.(option.value)}
86
+ className="sr-only"
87
+ />
88
+ <span>{option.label}</span>
89
+ </label>
90
+ )
91
+ })}
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import type { InputBoxStyleForEachStatus, Option } from '../types'
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { TextBox } from './TextBox'
6
+
7
+ type Props = {
8
+ options: Option[]
9
+ value: string
10
+ onChange?: (newValue: string) => void
11
+ onSelect?: (newValue: string) => void
12
+ name: string
13
+ placeholder?: string
14
+ isDisabled?: boolean
15
+ searchTarget?: 'label' | 'value'
16
+ cssStyle?: InputBoxStyleForEachStatus
17
+ }
18
+
19
+ export function SearchableSelectBox({
20
+ options,
21
+ value,
22
+ onChange,
23
+ onSelect,
24
+ name,
25
+ placeholder = '入力してください',
26
+ isDisabled,
27
+ searchTarget = 'label',
28
+ cssStyle,
29
+ }: Props) {
30
+ const [searchWord, setSearchWord] = useState(value)
31
+ const [isOpened, setIsOpened] = useState(false)
32
+ const rootRef = useRef<HTMLDivElement>(null)
33
+
34
+ // 親からの value 更新に追従(正本の watch(modelValue, immediate) 相当)
35
+ const lastValueProp = useRef(value)
36
+
37
+ useEffect(() => {
38
+ if (lastValueProp.current === value) return
39
+ lastValueProp.current = value
40
+ setSearchWord(value)
41
+ }, [value])
42
+
43
+ useEffect(() => {
44
+ const handleClickOutside = (event: PointerEvent) => {
45
+ const root = rootRef.current
46
+ if (root && !root.contains(event.target as Node)) setIsOpened(false)
47
+ }
48
+
49
+ document.addEventListener('pointerdown', handleClickOutside)
50
+ return () => document.removeEventListener('pointerdown', handleClickOutside)
51
+ }, [])
52
+
53
+ const filteredOptions = searchWord
54
+ ? options.filter((option) =>
55
+ String(option[searchTarget])
56
+ .toLowerCase()
57
+ .includes(searchWord.toLowerCase()),
58
+ )
59
+ : options
60
+
61
+ const handleInputChange = (newValue: string | number) => {
62
+ const word = String(newValue)
63
+ setSearchWord(word)
64
+
65
+ if (!word) {
66
+ setIsOpened(false)
67
+ return
68
+ }
69
+
70
+ setIsOpened(true)
71
+ onChange?.(word)
72
+ }
73
+
74
+ const selectOption = (option: Option) => {
75
+ const newValue = option.value.toString()
76
+ setSearchWord(newValue)
77
+ setIsOpened(false)
78
+ onChange?.(newValue)
79
+ onSelect?.(newValue)
80
+ }
81
+
82
+ return (
83
+ <div ref={rootRef} className="relative">
84
+ <TextBox
85
+ name={name}
86
+ value={searchWord}
87
+ onChange={handleInputChange}
88
+ isDisabled={isDisabled}
89
+ placeholder={placeholder}
90
+ cssStyle={cssStyle}
91
+ />
92
+ {isOpened && filteredOptions.length > 0 && (
93
+ <div className="absolute top-[calc(100%-var(--sp-min,0.1875rem))] left-0 z-[2] max-h-[calc(var(--bv,0.375rem)*56)] min-w-full overflow-auto rounded-[var(--radius-small,0.1875rem)] bg-white py-[var(--sp-small,0.375rem)] shadow-[0_0_6px_#33333333]">
94
+ {filteredOptions.map((option) => (
95
+ <button
96
+ key={option.value}
97
+ type="button"
98
+ className="block w-full cursor-pointer p-[var(--sp-medium,0.75rem)] text-left text-[length:var(--fs-small,0.6875rem)] text-[var(--link-color,#1c4ac9)] hover:bg-[var(--hover-color,#EEF7FB)] hover:transition-all hover:duration-100"
99
+ onClick={() => selectOption(option)}
100
+ >
101
+ {option.label}
102
+ </button>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,152 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import type { Option, InputBoxStyleForEachStatus, SelectValue } from '../types'
5
+ import { MESSAGES } from '@geckou/ui-core'
6
+ import { Fragment, useEffect, useRef, useState } from 'react'
7
+ import { InputBox } from './InputBox'
8
+ import { ErrorMessage } from './ErrorMessage'
9
+ import { KeyboardArrowDownIcon } from './icons/KeyboardArrowDownIcon'
10
+
11
+ type Props = {
12
+ options: Array<Option | Record<string, Option[]>>
13
+ name: string
14
+ value?: SelectValue
15
+ onChange?: (newValue: SelectValue) => void
16
+ cssStyle?: InputBoxStyleForEachStatus
17
+ placeholder?: string
18
+ canOmitSelect?: boolean
19
+ isDisabled?: boolean
20
+ isRequired?: boolean
21
+ arrow?: ReactNode
22
+ }
23
+
24
+ function isOption(obj: unknown): obj is Option {
25
+ return (
26
+ typeof obj === 'object' &&
27
+ obj !== null &&
28
+ 'label' in obj &&
29
+ typeof obj.label === 'string' &&
30
+ 'value' in obj
31
+ )
32
+ }
33
+
34
+ export function SelectBox({
35
+ options,
36
+ name,
37
+ value,
38
+ onChange,
39
+ cssStyle,
40
+ placeholder = '選択してください',
41
+ canOmitSelect,
42
+ isDisabled,
43
+ isRequired,
44
+ arrow,
45
+ }: Props) {
46
+ const [errorMessages, setErrorMessages] = useState<string[]>([])
47
+ const selectedValue = value ?? ''
48
+
49
+ const validateValue = () => {
50
+ const messages: string[] = []
51
+ // 数値 0 も正当な選択値として扱うため、truthy 判定ではなく空文字と比較する
52
+ if (selectedValue === '' && isRequired) messages.push(MESSAGES.required)
53
+ setErrorMessages(messages)
54
+ }
55
+
56
+ // Vue 版(@geckou/ui-vue)の watch(immediate: !!modelValue) と等価:
57
+ // 初期値ありならマウント時にも検証、以後は値が変化したときのみ検証
58
+ const initialValue = useRef(selectedValue)
59
+ const hasChanged = useRef(false)
60
+
61
+ useEffect(() => {
62
+ if (!hasChanged.current) {
63
+ if (selectedValue === initialValue.current) {
64
+ if (selectedValue !== '') validateValue()
65
+ return
66
+ }
67
+ hasChanged.current = true
68
+ }
69
+
70
+ validateValue()
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- Vue 版(@geckou/ui-vue)同様、値の変化時のみ検証する
72
+ }, [selectedValue])
73
+
74
+ const flattenedOptions = options.flatMap((option) =>
75
+ isOption(option) ? [option] : Object.values(option).flat(),
76
+ )
77
+
78
+ const handleChange = (rawValue: string) => {
79
+ const matched = flattenedOptions.find(
80
+ (option) => String(option.value) === rawValue,
81
+ )
82
+ onChange?.(matched ? matched.value : rawValue)
83
+ }
84
+
85
+ return (
86
+ <InputBox
87
+ cssStyle={cssStyle}
88
+ className="inline-flex [&>select]:flex-auto [&>select]:cursor-pointer [&>select]:pe-8"
89
+ isDisabled={isDisabled}
90
+ >
91
+ <select
92
+ name={name}
93
+ value={selectedValue}
94
+ disabled={isDisabled}
95
+ required={isRequired}
96
+ onChange={(event) => handleChange(event.target.value)}
97
+ onBlur={() => validateValue()}
98
+ >
99
+ {canOmitSelect ? (
100
+ <option value="">{placeholder || '選択しない'}</option>
101
+ ) : (
102
+ <option disabled value="">
103
+ {placeholder || '選択してください'}
104
+ </option>
105
+ )}
106
+ {['0', 'NA'].includes(selectedValue.toString()) && isDisabled && (
107
+ <option disabled value={selectedValue}>
108
+ {placeholder || '選択してください'}
109
+ </option>
110
+ )}
111
+ {options.map((option) =>
112
+ isOption(option) ? (
113
+ <option
114
+ key={option.value}
115
+ value={option.value}
116
+ disabled={option.isDisabled}
117
+ >
118
+ {option.label}
119
+ </option>
120
+ ) : (
121
+ <Fragment key={Object.keys(option)[0]}>
122
+ {Object.entries(option).map(([key, groupedOptions]) => (
123
+ <optgroup key={key} label={key}>
124
+ {groupedOptions.map((groupedOption) => (
125
+ <option
126
+ key={groupedOption.value}
127
+ value={groupedOption.value}
128
+ disabled={groupedOption.isDisabled}
129
+ >
130
+ {groupedOption.label}
131
+ </option>
132
+ ))}
133
+ </optgroup>
134
+ ))}
135
+ <hr />
136
+ </Fragment>
137
+ ),
138
+ )}
139
+ </select>
140
+ <div className="pointer-events-none absolute inset-y-0 end-2 flex items-center [&>*]:h-4 [&>*]:fill-current [&>*]:text-current">
141
+ {arrow ?? <KeyboardArrowDownIcon />}
142
+ </div>
143
+ <ErrorMessage
144
+ errorMessages={errorMessages}
145
+ cssStyle={{
146
+ textColor: cssStyle?.error?.backgroundColor,
147
+ backgroundColor: cssStyle?.error?.textColor,
148
+ }}
149
+ />
150
+ </InputBox>
151
+ )
152
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties, ReactNode, Ref } from 'react'
4
+ import { useEffect, useImperativeHandle, useRef, useState } from 'react'
5
+ import { KeyboardArrowDownIcon } from './icons/KeyboardArrowDownIcon'
6
+ import { COLOR } from '../constants'
7
+
8
+ // Vue 版(@geckou/ui-vue)の defineExpose({ isOpenedContents }) 相当
9
+ export type SlideDownUiHandle = {
10
+ isOpenedContents: () => boolean
11
+ close: () => void
12
+ }
13
+
14
+ type Props = {
15
+ isOpened?: boolean | null
16
+ isHiddenArrow?: boolean
17
+ isDisabled?: boolean
18
+ isDisableClickOutside?: boolean
19
+ duration?: number
20
+ trigger: ReactNode
21
+ children: ReactNode
22
+ ref?: Ref<SlideDownUiHandle>
23
+ }
24
+
25
+ export function SlideDownUi({
26
+ isOpened = null,
27
+ isHiddenArrow = false,
28
+ isDisabled = false,
29
+ isDisableClickOutside = false,
30
+ duration = 0.3,
31
+ trigger,
32
+ children,
33
+ ref,
34
+ }: Props) {
35
+ const [isOpenedContents, setIsOpenedContents] = useState(isOpened || false)
36
+ const [isOverflowVisible, setIsOverflowVisible] = useState(isOpened || false)
37
+ const [contentsHeight, setContentsHeight] = useState(0)
38
+ const rootRef = useRef<HTMLDivElement>(null)
39
+ const contentsRef = useRef<HTMLDivElement>(null)
40
+
41
+ const toggleBox = () => setIsOpenedContents((current) => !current)
42
+
43
+ useImperativeHandle(ref, () => ({
44
+ isOpenedContents: () => isOpenedContents,
45
+ close: () => setIsOpenedContents(false),
46
+ }))
47
+
48
+ useEffect(() => {
49
+ if (typeof isOpened === 'boolean') setIsOpenedContents(isOpened)
50
+ }, [isOpened])
51
+
52
+ useEffect(() => {
53
+ if (isDisableClickOutside) return
54
+
55
+ const handleClickOutside = (event: PointerEvent) => {
56
+ const root = rootRef.current
57
+ if (root && !root.contains(event.target as Node))
58
+ {setIsOpenedContents(false)}
59
+ }
60
+
61
+ document.addEventListener('pointerdown', handleClickOutside)
62
+ return () => document.removeEventListener('pointerdown', handleClickOutside)
63
+ }, [isDisableClickOutside])
64
+
65
+ useEffect(() => {
66
+ const contentsElement = contentsRef.current
67
+ if (!contentsElement) return
68
+
69
+ const updateContentsHeight = () =>
70
+ setContentsHeight(contentsElement.clientHeight)
71
+
72
+ updateContentsHeight()
73
+ const observer = new ResizeObserver(updateContentsHeight)
74
+ observer.observe(contentsElement)
75
+ return () => observer.disconnect()
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- サイズ変化は ResizeObserver が検知するため、マウント時の1回だけ登録する
77
+ }, [])
78
+
79
+ useEffect(() => {
80
+ if (!isOpenedContents) {
81
+ setIsOverflowVisible(false)
82
+ return
83
+ }
84
+
85
+ const timer = setTimeout(() => setIsOverflowVisible(true), duration * 1000)
86
+ return () => clearTimeout(timer)
87
+ }, [isOpenedContents, duration])
88
+
89
+ const style = { '--link-color': COLOR.blue } as CSSProperties
90
+
91
+ return (
92
+ <div ref={rootRef} style={style}>
93
+ <button
94
+ type="button"
95
+ disabled={isDisabled}
96
+ aria-expanded={isOpenedContents}
97
+ className="relative grid w-full cursor-pointer grid-cols-[1fr_auto] items-center justify-items-start text-(--link-color)"
98
+ onClick={(event) => {
99
+ event.preventDefault()
100
+ toggleBox()
101
+ }}
102
+ >
103
+ <div className="w-full text-left">{trigger}</div>
104
+ {!isHiddenArrow && (
105
+ <KeyboardArrowDownIcon
106
+ className={`size-[var(--icon-medium,1.125rem)] flex-none text-(--link-color) transition-all duration-100 ${isOpenedContents ? 'rotate-180' : ''}`}
107
+ />
108
+ )}
109
+ </button>
110
+ <div
111
+ style={{
112
+ height: isOpenedContents ? `${contentsHeight}px` : 0,
113
+ transitionDuration: `${duration}s`,
114
+ overflow: isOverflowVisible ? 'visible' : 'hidden',
115
+ }}
116
+ inert={!isOpenedContents}
117
+ className="transition-[height]"
118
+ >
119
+ <div ref={contentsRef}>{children}</div>
120
+ </div>
121
+ </div>
122
+ )
123
+ }