@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,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'
|
|
4
|
+
import { useId, useRef, useState } from 'react'
|
|
5
|
+
import { COLOR } from '../constants'
|
|
6
|
+
|
|
7
|
+
type Tab = {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
tabs: Tab[]
|
|
14
|
+
color?: {
|
|
15
|
+
active: string
|
|
16
|
+
background: string
|
|
17
|
+
text: string
|
|
18
|
+
}
|
|
19
|
+
type?: 'tab' | 'button' | 'border'
|
|
20
|
+
initialIndex?: number
|
|
21
|
+
tabSlots?: Record<string, ReactNode>
|
|
22
|
+
panelSlots?: Record<string, ReactNode>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TabUI({
|
|
26
|
+
tabs,
|
|
27
|
+
color,
|
|
28
|
+
initialIndex = 0,
|
|
29
|
+
tabSlots,
|
|
30
|
+
panelSlots,
|
|
31
|
+
}: Props) {
|
|
32
|
+
const [activeTab, setActiveTab] = useState(
|
|
33
|
+
() => tabs[initialIndex]?.key ?? tabs[0]?.key ?? '',
|
|
34
|
+
)
|
|
35
|
+
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
|
|
36
|
+
// 複数インスタンス設置時の DOM id 重複を避ける
|
|
37
|
+
const uid = useId()
|
|
38
|
+
const tabId = (key: string) => `${uid}-tab-${key}`
|
|
39
|
+
const panelId = (key: string) => `${uid}-panel-${key}`
|
|
40
|
+
|
|
41
|
+
const changeTabs = (key: string) => setActiveTab(key)
|
|
42
|
+
|
|
43
|
+
const activateTab = (index: number) => {
|
|
44
|
+
setActiveTab(tabs[index].key)
|
|
45
|
+
tabRefs.current[index]?.focus()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Vue 版(@geckou/ui-vue)は window 全体に keydown を張っていたため、フォーカス位置と
|
|
49
|
+
// 無関係にタブが切り替わり複数設置時に競合した。タブリストにフォーカスが
|
|
50
|
+
// あるときだけ矢印キーで移動する(WAI-ARIA Tabs パターン)
|
|
51
|
+
const handleKeydown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
52
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
53
|
+
|
|
54
|
+
const currentIndex = tabs.findIndex((tab) => tab.key === activeTab)
|
|
55
|
+
if (currentIndex === -1) return
|
|
56
|
+
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
const lastIndex = tabs.length - 1
|
|
59
|
+
|
|
60
|
+
if (event.key === 'ArrowLeft') {
|
|
61
|
+
activateTab(currentIndex > 0 ? currentIndex - 1 : lastIndex)
|
|
62
|
+
} else {
|
|
63
|
+
activateTab(currentIndex < lastIndex ? currentIndex + 1 : 0)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const style = {
|
|
68
|
+
'--active-color': color?.active || COLOR.blue,
|
|
69
|
+
'--background-color': color?.background || 'transparent',
|
|
70
|
+
} as CSSProperties
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div>
|
|
74
|
+
<div
|
|
75
|
+
style={style}
|
|
76
|
+
className="flex bg-(--background-color)"
|
|
77
|
+
role="tablist"
|
|
78
|
+
onKeyDown={handleKeydown}
|
|
79
|
+
>
|
|
80
|
+
{tabs.map((tab, index) => (
|
|
81
|
+
<button
|
|
82
|
+
key={tab.key}
|
|
83
|
+
id={tabId(tab.key)}
|
|
84
|
+
ref={(el) => {
|
|
85
|
+
tabRefs.current[index] = el
|
|
86
|
+
}}
|
|
87
|
+
type="button"
|
|
88
|
+
role="tab"
|
|
89
|
+
aria-controls={panelId(tab.key)}
|
|
90
|
+
aria-selected={activeTab === tab.key}
|
|
91
|
+
tabIndex={activeTab === tab.key ? 0 : -1}
|
|
92
|
+
className={`border-none bg-transparent px-4 py-2 text-base ${activeTab === tab.key ? 'cursor-auto' : 'cursor-pointer'}`}
|
|
93
|
+
onClick={() => changeTabs(tab.key)}
|
|
94
|
+
>
|
|
95
|
+
{tabSlots?.[tab.key] ?? tab.label}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
{tabs.map((tab) => (
|
|
100
|
+
<div
|
|
101
|
+
key={`${tab.key}_panel`}
|
|
102
|
+
id={panelId(tab.key)}
|
|
103
|
+
role="tabpanel"
|
|
104
|
+
aria-labelledby={tabId(tab.key)}
|
|
105
|
+
hidden={activeTab !== tab.key}
|
|
106
|
+
>
|
|
107
|
+
{panelSlots?.[`${tab.key}Contents`]}
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { InputBoxStyleForEachStatus, Validates } from '../types'
|
|
4
|
+
import { isEmptyValue, validateInputValue } from '@geckou/ui-core'
|
|
5
|
+
import { useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { InputBox } from './InputBox'
|
|
7
|
+
import { ErrorMessage } from './ErrorMessage'
|
|
8
|
+
|
|
9
|
+
type InputValue = string | null
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
name: string
|
|
13
|
+
value?: InputValue
|
|
14
|
+
onChange?: (newValue: InputValue) => void
|
|
15
|
+
cssStyle?: InputBoxStyleForEachStatus
|
|
16
|
+
placeholder?: string
|
|
17
|
+
isDisabled?: boolean
|
|
18
|
+
isRequired?: boolean
|
|
19
|
+
rows?: number
|
|
20
|
+
maxLength?: number
|
|
21
|
+
autocomplete?: string
|
|
22
|
+
validates?: Validates
|
|
23
|
+
autoAdjustHeight?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function TextArea({
|
|
27
|
+
name,
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
cssStyle,
|
|
31
|
+
placeholder = '入力してください',
|
|
32
|
+
isDisabled,
|
|
33
|
+
isRequired,
|
|
34
|
+
rows,
|
|
35
|
+
maxLength = 100,
|
|
36
|
+
autocomplete = 'off',
|
|
37
|
+
validates = [],
|
|
38
|
+
autoAdjustHeight,
|
|
39
|
+
}: Props) {
|
|
40
|
+
const [errorMessages, setErrorMessages] = useState<string[]>([])
|
|
41
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
42
|
+
const inputValue = value ?? ''
|
|
43
|
+
|
|
44
|
+
const validateValue = () => {
|
|
45
|
+
setErrorMessages(validateInputValue(inputValue, { isRequired, validates }))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Vue 版(@geckou/ui-vue)の watch(immediate: !!modelValue) と等価:
|
|
49
|
+
// 初期値ありならマウント時にも検証、以後は値が変化したときのみ検証
|
|
50
|
+
const initialValue = useRef(inputValue)
|
|
51
|
+
const hasChanged = useRef(false)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (autoAdjustHeight && textareaRef.current) {
|
|
55
|
+
textareaRef.current.style.height = 'auto'
|
|
56
|
+
textareaRef.current.style.height = `calc(${textareaRef.current.scrollHeight}px - 2rem)`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!hasChanged.current) {
|
|
60
|
+
if (inputValue === initialValue.current) {
|
|
61
|
+
// 数値 0 も初期値として扱うため truthy 判定は使わない
|
|
62
|
+
if (!isEmptyValue(inputValue)) validateValue()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
hasChanged.current = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
validateValue()
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Vue 版(@geckou/ui-vue)同様、値の変化時のみ検証・高さ調整する
|
|
70
|
+
}, [inputValue])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<InputBox
|
|
74
|
+
cssStyle={cssStyle}
|
|
75
|
+
className="min-h-[4em]"
|
|
76
|
+
isErrored={!!errorMessages.length}
|
|
77
|
+
isDisabled={isDisabled}
|
|
78
|
+
>
|
|
79
|
+
<textarea
|
|
80
|
+
ref={textareaRef}
|
|
81
|
+
name={name}
|
|
82
|
+
value={inputValue}
|
|
83
|
+
required={isRequired}
|
|
84
|
+
placeholder={placeholder}
|
|
85
|
+
disabled={isDisabled}
|
|
86
|
+
autoComplete={autocomplete}
|
|
87
|
+
rows={rows}
|
|
88
|
+
maxLength={maxLength}
|
|
89
|
+
aria-invalid={errorMessages.length ? 'true' : undefined}
|
|
90
|
+
onChange={(event) => onChange?.(event.target.value)}
|
|
91
|
+
onBlur={() => validateValue()}
|
|
92
|
+
/>
|
|
93
|
+
<ErrorMessage
|
|
94
|
+
errorMessages={errorMessages}
|
|
95
|
+
cssStyle={{
|
|
96
|
+
textColor: cssStyle?.error?.backgroundColor,
|
|
97
|
+
backgroundColor: cssStyle?.error?.textColor,
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
</InputBox>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { InputBoxStyleForEachStatus, Validates } from '../types'
|
|
5
|
+
import {
|
|
6
|
+
convertFullWidthToHalfWidth,
|
|
7
|
+
isEmptyValue,
|
|
8
|
+
validateInputValue,
|
|
9
|
+
} from '@geckou/ui-core'
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import { InputBox } from './InputBox'
|
|
12
|
+
import { ErrorMessage } from './ErrorMessage'
|
|
13
|
+
|
|
14
|
+
type InputValue = string | number
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
name: string
|
|
18
|
+
value?: InputValue
|
|
19
|
+
onChange?: (newValue: InputValue) => void
|
|
20
|
+
cssStyle?: InputBoxStyleForEachStatus
|
|
21
|
+
inputType?: string
|
|
22
|
+
placeholder?: string
|
|
23
|
+
isDisabled?: boolean
|
|
24
|
+
isRequired?: boolean
|
|
25
|
+
maxLength?: number
|
|
26
|
+
autocomplete?: string
|
|
27
|
+
validates?: Validates
|
|
28
|
+
before?: ReactNode
|
|
29
|
+
after?: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function TextBox({
|
|
33
|
+
name,
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
cssStyle,
|
|
37
|
+
inputType = 'text',
|
|
38
|
+
placeholder = '入力してください',
|
|
39
|
+
isDisabled,
|
|
40
|
+
isRequired,
|
|
41
|
+
maxLength = 30,
|
|
42
|
+
autocomplete = 'off',
|
|
43
|
+
validates = [],
|
|
44
|
+
before,
|
|
45
|
+
after,
|
|
46
|
+
}: Props) {
|
|
47
|
+
const [errorMessages, setErrorMessages] = useState<string[]>([])
|
|
48
|
+
const inputValue = value ?? ''
|
|
49
|
+
|
|
50
|
+
const validateValue = () => {
|
|
51
|
+
setErrorMessages(validateInputValue(inputValue, { isRequired, validates }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Vue 版(@geckou/ui-vue)の watch(immediate: !!modelValue) と等価:
|
|
55
|
+
// 初期値ありならマウント時にも検証、以後は値が変化したときのみ検証
|
|
56
|
+
const initialValue = useRef(inputValue)
|
|
57
|
+
const hasChanged = useRef(false)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!hasChanged.current) {
|
|
61
|
+
if (inputValue === initialValue.current) {
|
|
62
|
+
if (!isEmptyValue(inputValue)) validateValue()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
hasChanged.current = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
validateValue()
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Vue 版(@geckou/ui-vue)同様、値の変化時のみ検証する
|
|
70
|
+
}, [inputValue])
|
|
71
|
+
|
|
72
|
+
const isComposing = useRef(false)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<InputBox
|
|
76
|
+
cssStyle={cssStyle}
|
|
77
|
+
className="inline-flex [&>input]:flex-auto"
|
|
78
|
+
isErrored={!!errorMessages.length}
|
|
79
|
+
isDisabled={isDisabled}
|
|
80
|
+
>
|
|
81
|
+
{before}
|
|
82
|
+
<input
|
|
83
|
+
type={inputType}
|
|
84
|
+
name={name}
|
|
85
|
+
value={inputValue}
|
|
86
|
+
required={isRequired}
|
|
87
|
+
placeholder={placeholder}
|
|
88
|
+
disabled={isDisabled}
|
|
89
|
+
autoComplete={autocomplete}
|
|
90
|
+
maxLength={maxLength}
|
|
91
|
+
aria-invalid={errorMessages.length ? 'true' : undefined}
|
|
92
|
+
onChange={(event) => {
|
|
93
|
+
// IME 変換中に全角→半角変換すると未確定文字列が壊れるため、確定後に変換する
|
|
94
|
+
const rawValue = event.target.value
|
|
95
|
+
onChange?.(
|
|
96
|
+
isComposing.current
|
|
97
|
+
? rawValue
|
|
98
|
+
: convertFullWidthToHalfWidth(rawValue),
|
|
99
|
+
)
|
|
100
|
+
}}
|
|
101
|
+
onCompositionStart={() => {
|
|
102
|
+
isComposing.current = true
|
|
103
|
+
}}
|
|
104
|
+
onCompositionEnd={(event) => {
|
|
105
|
+
isComposing.current = false
|
|
106
|
+
onChange?.(convertFullWidthToHalfWidth(event.currentTarget.value))
|
|
107
|
+
}}
|
|
108
|
+
onBlur={() => validateValue()}
|
|
109
|
+
/>
|
|
110
|
+
{after}
|
|
111
|
+
<ErrorMessage
|
|
112
|
+
errorMessages={errorMessages}
|
|
113
|
+
cssStyle={{
|
|
114
|
+
textColor: cssStyle?.error?.backgroundColor,
|
|
115
|
+
backgroundColor: cssStyle?.error?.textColor,
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
</InputBox>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from 'react'
|
|
4
|
+
import type { BaseStyle, StyleForEachStatus } from '../types'
|
|
5
|
+
import { COLOR } from '../constants'
|
|
6
|
+
|
|
7
|
+
type ToggleStyle = {
|
|
8
|
+
on: BaseStyle
|
|
9
|
+
off: BaseStyle
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
name: string
|
|
14
|
+
label?: Record<'on' | 'off', string>
|
|
15
|
+
checked?: boolean
|
|
16
|
+
onChange?: (newValue: boolean) => void
|
|
17
|
+
isDisabled?: boolean
|
|
18
|
+
cssStyle?: StyleForEachStatus<ToggleStyle>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ToggleButton({
|
|
22
|
+
name,
|
|
23
|
+
label = { on: 'ON', off: 'OFF' },
|
|
24
|
+
checked,
|
|
25
|
+
onChange,
|
|
26
|
+
isDisabled,
|
|
27
|
+
cssStyle,
|
|
28
|
+
}: Props) {
|
|
29
|
+
const isChecked = checked ?? false
|
|
30
|
+
const maxTextLength = Math.max(label.on.length, label.off.length)
|
|
31
|
+
const baseStyle = isDisabled ? cssStyle?.disabled : cssStyle?.default
|
|
32
|
+
|
|
33
|
+
const currentCssStyle = {
|
|
34
|
+
on: {
|
|
35
|
+
textColor: isDisabled ? COLOR.lightGray : COLOR.white,
|
|
36
|
+
backgroundColor: isDisabled ? COLOR.gray : COLOR.blue,
|
|
37
|
+
border: {
|
|
38
|
+
color: isDisabled ? COLOR.gray : COLOR.blue,
|
|
39
|
+
size: '1px',
|
|
40
|
+
radius: '.25rem',
|
|
41
|
+
},
|
|
42
|
+
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0)',
|
|
43
|
+
...baseStyle?.on,
|
|
44
|
+
},
|
|
45
|
+
off: {
|
|
46
|
+
textColor: isDisabled ? COLOR.gray : COLOR.white,
|
|
47
|
+
backgroundColor: isDisabled ? COLOR.lightGray : COLOR.darkGray,
|
|
48
|
+
border: {
|
|
49
|
+
color: isDisabled ? COLOR.gray : COLOR.darkGray,
|
|
50
|
+
size: '1px',
|
|
51
|
+
radius: '.25rem',
|
|
52
|
+
},
|
|
53
|
+
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0)',
|
|
54
|
+
...baseStyle?.off,
|
|
55
|
+
},
|
|
56
|
+
}[isChecked ? 'on' : 'off']
|
|
57
|
+
|
|
58
|
+
const style = {
|
|
59
|
+
'--text-color': currentCssStyle.textColor,
|
|
60
|
+
'--border-color': currentCssStyle.border?.color,
|
|
61
|
+
'--border-size': currentCssStyle.border?.size,
|
|
62
|
+
'--radius-size': currentCssStyle.border?.radius,
|
|
63
|
+
'--background-color': currentCssStyle.backgroundColor,
|
|
64
|
+
'--box-shadow': currentCssStyle.boxShadow,
|
|
65
|
+
'--inline-size': `${maxTextLength * 2}ch`,
|
|
66
|
+
'--handle-size': '1.5rem',
|
|
67
|
+
'--padding-size': 'calc(var(--border-size) + 2px)',
|
|
68
|
+
'--duration': '.15s',
|
|
69
|
+
} as CSSProperties
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
style={style}
|
|
75
|
+
disabled={isDisabled}
|
|
76
|
+
aria-pressed={isChecked}
|
|
77
|
+
aria-label={name}
|
|
78
|
+
onClick={(event) => {
|
|
79
|
+
event.stopPropagation()
|
|
80
|
+
if (!isDisabled) onChange?.(!isChecked)
|
|
81
|
+
}}
|
|
82
|
+
className="relative inline-block w-[calc(var(--inline-size)+var(--handle-size)+(var(--padding-size)*2))] cursor-pointer rounded-(--radius-size) border-none bg-(--background-color) p-(--padding-size) shadow-[0_0_0_var(--border-size)_var(--border-color)_inset,var(--box-shadow)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--border-color)"
|
|
83
|
+
>
|
|
84
|
+
<input
|
|
85
|
+
type="checkbox"
|
|
86
|
+
name={name}
|
|
87
|
+
checked={isChecked}
|
|
88
|
+
disabled={isDisabled}
|
|
89
|
+
readOnly
|
|
90
|
+
className="hidden"
|
|
91
|
+
/>
|
|
92
|
+
<div
|
|
93
|
+
data-on={label.on}
|
|
94
|
+
data-off={label.off}
|
|
95
|
+
className={`absolute top-0 left-0 h-full w-full text-(--text-color) uppercase before:absolute before:right-0 before:m-auto before:inline-flex before:h-full before:w-[calc(100%-var(--handle-size)-var(--padding-size))] before:items-center before:justify-center before:leading-none before:transition-opacity before:duration-(--duration) before:ease-out before:content-[attr(data-off)] after:absolute after:left-0 after:m-auto after:inline-flex after:h-full after:w-[calc(100%-var(--handle-size)-var(--padding-size))] after:items-center after:justify-center after:leading-none after:transition-opacity after:duration-(--duration) after:ease-out after:content-[attr(data-on)] ${isChecked ? 'before:opacity-0 after:opacity-100' : 'before:opacity-100 after:opacity-0'}`}
|
|
96
|
+
/>
|
|
97
|
+
<div
|
|
98
|
+
className={`relative m-0 aspect-square w-(--handle-size) rounded-[calc(var(--radius-size)-(var(--padding-size)/2))] bg-(--text-color) shadow-(--box-shadow) transition-[left] duration-(--duration) ease-out ${isChecked ? 'left-[calc(100%-var(--handle-size))]' : 'left-0'}`}
|
|
99
|
+
/>
|
|
100
|
+
</button>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
className?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function BackupIcon({ className }: Props) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 -960 960 960"
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
className={className}
|
|
12
|
+
>
|
|
13
|
+
<path d="M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Z" />
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
className?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function CalendarIcon({ className }: Props) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 -960 960 960"
|
|
10
|
+
className={className}
|
|
11
|
+
>
|
|
12
|
+
<path d="M200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-80h80v80h320v-80h80v80h40q33 0 56.5 23.5T840-720v560q0 33-23.5 56.5T760-80H200Zm0-80h560v-400H200v400Zm0-480h560v-80H200v80Z" />
|
|
13
|
+
</svg>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
className?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function CloseIcon({ className }: Props) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 -960 960 960"
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
className={className}
|
|
12
|
+
>
|
|
13
|
+
<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" />
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
className?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function KeyboardArrowDownIcon({ className }: Props) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 -960 960 960"
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
className={className}
|
|
12
|
+
>
|
|
13
|
+
<path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z" />
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
|
4
|
+
import { createFormValidationStore } from '@geckou/ui-core'
|
|
5
|
+
import type { FormValidationStore } from '@geckou/ui-core'
|
|
6
|
+
|
|
7
|
+
export type UseFormValidationResult = {
|
|
8
|
+
/** 登録済みの入力がすべて有効かどうか */
|
|
9
|
+
isAllValid: boolean
|
|
10
|
+
/** 無効になっている入力の name 一覧 */
|
|
11
|
+
invalidNames: string[]
|
|
12
|
+
/** 入力の状態を登録・更新する */
|
|
13
|
+
setValid: FormValidationStore['setValid']
|
|
14
|
+
/** 管理対象から外す */
|
|
15
|
+
remove: FormValidationStore['remove']
|
|
16
|
+
/** すべての状態を破棄する */
|
|
17
|
+
reset: FormValidationStore['reset']
|
|
18
|
+
/** 子コンポーネントへ直接渡すためのストア本体 */
|
|
19
|
+
store: FormValidationStore
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* フォーム内の各入力のバリデーション状態をまとめて管理する。
|
|
24
|
+
*
|
|
25
|
+
* 状態そのものは @geckou/ui-core の createFormValidationStore が持つため、
|
|
26
|
+
* 判定ロジックは Vue 実装(@geckou/ui-vue の FormValidationManager)と共有される。
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const { isAllValid, store } = useFormValidation()
|
|
30
|
+
* // <DatePicker name="startedOn" formValidationStore={store} />
|
|
31
|
+
* <button disabled={!isAllValid}>送信</button>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useFormValidation(): UseFormValidationResult {
|
|
35
|
+
const store = useMemo(() => createFormValidationStore(), [])
|
|
36
|
+
const snapshot = useSyncExternalStore(
|
|
37
|
+
store.subscribe,
|
|
38
|
+
store.getSnapshot,
|
|
39
|
+
// SSR 時は登録がまだ無いので、すべて有効なスナップショットを返す
|
|
40
|
+
store.getSnapshot,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
isAllValid: snapshot.isAllValid,
|
|
45
|
+
invalidNames: snapshot.invalidNames,
|
|
46
|
+
setValid: store.setValid,
|
|
47
|
+
remove: store.remove,
|
|
48
|
+
reset: store.reset,
|
|
49
|
+
store,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 入力コンポーネント側から検証結果をストアへ通知する。
|
|
55
|
+
* アンマウント時には登録を解除し、無効判定が残らないようにする。
|
|
56
|
+
*/
|
|
57
|
+
export function useRegisterValidation(
|
|
58
|
+
store: FormValidationStore | null | undefined,
|
|
59
|
+
name: string,
|
|
60
|
+
isValid: boolean,
|
|
61
|
+
): void {
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
store?.setValid(name, isValid)
|
|
64
|
+
}, [store, name, isValid])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
return () => store?.remove(name)
|
|
68
|
+
}, [store, name])
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export { BasicButton } from './components/BasicButton'
|
|
2
|
+
export { CheckBox } from './components/CheckBox'
|
|
3
|
+
export { CheckBoxes } from './components/CheckBoxes'
|
|
4
|
+
export { CheckButton } from './components/CheckButton'
|
|
5
|
+
export { DatePicker } from './components/DatePicker'
|
|
6
|
+
export { DateRangePicker } from './components/DateRangePicker'
|
|
7
|
+
export { DateSelector } from './components/DateSelector'
|
|
8
|
+
export { DropdownUi } from './components/DropdownUi'
|
|
9
|
+
export type { DropdownUiHandle } from './components/DropdownUi'
|
|
10
|
+
export { ErrorMessage } from './components/ErrorMessage'
|
|
11
|
+
export { FileInput } from './components/FileInput'
|
|
12
|
+
export { InputBox } from './components/InputBox'
|
|
13
|
+
export { InputGroup } from './components/InputGroup'
|
|
14
|
+
export { LabeledCheckbox } from './components/LabeledCheckbox'
|
|
15
|
+
export { LabeledFieldset } from './components/LabeledFieldset'
|
|
16
|
+
export { LoadingSpinner } from './components/LoadingSpinner'
|
|
17
|
+
export { ModalBox } from './components/ModalBox'
|
|
18
|
+
export { PopupBox } from './components/PopupBox'
|
|
19
|
+
export type { PopupBoxHandle } from './components/PopupBox'
|
|
20
|
+
export { RadioButtons } from './components/RadioButtons'
|
|
21
|
+
export { SearchableSelectBox } from './components/SearchableSelectBox'
|
|
22
|
+
export { SelectBox } from './components/SelectBox'
|
|
23
|
+
export { SlideDownUi } from './components/SlideDownUi'
|
|
24
|
+
export type { SlideDownUiHandle } from './components/SlideDownUi'
|
|
25
|
+
export { TabUI } from './components/TabUI'
|
|
26
|
+
export { TextArea } from './components/TextArea'
|
|
27
|
+
export { TextBox } from './components/TextBox'
|
|
28
|
+
export { ToggleButton } from './components/ToggleButton'
|
|
29
|
+
|
|
30
|
+
export { BackupIcon } from './components/icons/BackupIcon'
|
|
31
|
+
export { CheckIcon } from './components/icons/CheckIcon'
|
|
32
|
+
export { CloseIcon } from './components/icons/CloseIcon'
|
|
33
|
+
export { CalendarIcon } from './components/icons/CalendarIcon'
|
|
34
|
+
export { KeyboardArrowDownIcon } from './components/icons/KeyboardArrowDownIcon'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
useFormValidation,
|
|
38
|
+
useRegisterValidation,
|
|
39
|
+
} from './hooks/useFormValidation'
|
|
40
|
+
export type { UseFormValidationResult } from './hooks/useFormValidation'
|
|
41
|
+
|
|
42
|
+
export { COLOR, BORDER, MESSAGES, INPUT_BOX_DEFAULT_STYLES } from './constants'
|
|
43
|
+
export type {
|
|
44
|
+
StateVariation,
|
|
45
|
+
BorderStyle,
|
|
46
|
+
BaseStyle,
|
|
47
|
+
StyleForEachStatus,
|
|
48
|
+
InputBoxStyle,
|
|
49
|
+
InputBoxStyleForEachStatus,
|
|
50
|
+
ButtonStyle,
|
|
51
|
+
ButtonStyleForEachStatus,
|
|
52
|
+
CheckBoxStyle,
|
|
53
|
+
CheckBoxStyleForEachStatus,
|
|
54
|
+
RadioButtonStyle,
|
|
55
|
+
RadioButtonStyleForEachStatus,
|
|
56
|
+
SelectValue,
|
|
57
|
+
Option,
|
|
58
|
+
Validate,
|
|
59
|
+
Validates,
|
|
60
|
+
InputValue,
|
|
61
|
+
DateObject,
|
|
62
|
+
DateType,
|
|
63
|
+
ValidationResult,
|
|
64
|
+
} from './types'
|
|
65
|
+
|
|
66
|
+
export type { FormValidationStore } from '@geckou/ui-core'
|