@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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/package.json +52 -0
- package/src/components/BasicButton.tsx +81 -0
- package/src/components/CheckBox.tsx +87 -0
- package/src/components/CheckBoxes.tsx +93 -0
- package/src/components/CheckButton.tsx +66 -0
- package/src/components/DatePicker.tsx +154 -0
- package/src/components/DateRangePicker.tsx +59 -0
- package/src/components/DateSelector.tsx +162 -0
- package/src/components/DropdownUi.tsx +126 -0
- package/src/components/ErrorMessage.tsx +30 -0
- package/src/components/FileInput.tsx +93 -0
- package/src/components/InputBox.tsx +118 -0
- package/src/components/InputGroup.tsx +13 -0
- package/src/components/LabeledCheckbox.tsx +61 -0
- package/src/components/LabeledFieldset.tsx +18 -0
- package/src/components/LoadingSpinner.tsx +23 -0
- package/src/components/ModalBox.tsx +109 -0
- package/src/components/PopupBox.tsx +70 -0
- package/src/components/RadioButtons.tsx +94 -0
- package/src/components/SearchableSelectBox.tsx +108 -0
- package/src/components/SelectBox.tsx +152 -0
- package/src/components/SlideDownUi.tsx +123 -0
- package/src/components/TabUI.tsx +112 -0
- package/src/components/TextArea.tsx +102 -0
- package/src/components/TextBox.tsx +120 -0
- package/src/components/ToggleButton.tsx +102 -0
- package/src/components/icons/BackupIcon.tsx +16 -0
- package/src/components/icons/CalendarIcon.tsx +15 -0
- package/src/components/icons/CheckIcon.tsx +7 -0
- package/src/components/icons/CloseIcon.tsx +16 -0
- package/src/components/icons/KeyboardArrowDownIcon.tsx +16 -0
- package/src/constants/index.ts +2 -0
- package/src/hooks/useFormValidation.ts +69 -0
- package/src/index.ts +66 -0
- package/src/styles/tokens.css +61 -0
- 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
|
+
}
|