@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Geckou LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @geckou/ui-react
2
+
3
+ Web(React 19 / Next.js)用の UI コンポーネント集。
4
+
5
+ ロジックは [`@geckou/ui-core`](../core) が持ち、このパッケージは DOM の記述だけを担当する。
6
+ 同じ core を [`@geckou/ui-vue`](../vue) も使うため、バリデーション・日付処理の挙動は両者で一致する。
7
+
8
+ Mobile(Expo / NativeWind)では使えない(DOM 前提のため)。
9
+
10
+ ## インストール
11
+
12
+ ```bash
13
+ yarn add @geckou/ui-react
14
+ ```
15
+
16
+ ソースをそのまま配布しているため、利用側でトランスパイルの対象に含める。
17
+
18
+ 1. `next.config.ts` の `transpilePackages` に `'@geckou/ui-react'` を追加
19
+ 2. `tailwind.config.ts` の `content` に `'./node_modules/@geckou/ui-react/src/**/*.{ts,tsx}'` を追加
20
+ 3. グローバル CSS に `@import '@geckou/ui-react/styles/tokens.css';` を追加(デザイントークン)
21
+
22
+ `tokens.css` は既定値です。上書きする変数の一覧はリポジトリの [README](../../README.md#design-tokens) を参照してください。
23
+
24
+ ## 使い方
25
+
26
+ ```tsx
27
+ import { TextBox, BasicButton, ModalBox } from '@geckou/ui-react'
28
+ ```
29
+
30
+ ### フォーム全体の検証状態
31
+
32
+ `useFormValidation` で各入力の検証結果を集約する。
33
+ Vue 版の `FormValidationManager` と同じストア(`@geckou/ui-core`)を使う。
34
+
35
+ ```tsx
36
+ const { isAllValid, store } = useFormValidation()
37
+
38
+ return (
39
+ <form>
40
+ <DatePicker name="startedOn" isRequired />
41
+ <button disabled={!isAllValid}>送信</button>
42
+ </form>
43
+ )
44
+ ```
45
+
46
+ ## 収録コンポーネント
47
+
48
+ フォーム系 25 種(`TextBox` / `TextArea` / `SelectBox` / `SearchableSelectBox` / `CheckBox` 系 /
49
+ `RadioButtons` / `ToggleButton` / `DatePicker` / `DateRangePicker` / `DateSelector` /
50
+ `FileInput` / `ModalBox` / `PopupBox` / `DropdownUi` / `SlideDownUi` / `TabUI` ほか)とアイコン 5 種。
51
+
52
+ 記事一覧(ArticleList)は Vue 版のみに収録している。
53
+
54
+ ## テスト
55
+
56
+ ```bash
57
+ yarn workspace @geckou/ui-react test
58
+ ```
59
+
60
+ ## License
61
+
62
+ [MIT](../../LICENSE)
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@geckou/ui-react",
3
+ "version": "0.1.0",
4
+ "description": "A set of reusable React UI components (forms) by Geckou",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./styles/tokens.css": "./src/styles/tokens.css",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/geckou/ui.git",
24
+ "directory": "packages/react"
25
+ },
26
+ "author": "geckou-shogo <shogo.nojima@geckou.net>",
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=20.0.0"
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org/",
33
+ "access": "public"
34
+ },
35
+ "peerDependencies": {
36
+ "react": "^19.0.0",
37
+ "react-dom": "^19.0.0"
38
+ },
39
+ "dependencies": {
40
+ "@geckou/ui-core": "^0.1.0"
41
+ },
42
+ "scripts": {
43
+ "build": "echo 'ソースをそのまま配布する(利用側で transpilePackages に指定する)'",
44
+ "type-check": "tsc --noEmit",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ },
48
+ "devDependencies": {
49
+ "@types/react": "^19.0.0",
50
+ "@types/react-dom": "^19.0.0"
51
+ }
52
+ }
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'
4
+ import type { ButtonStyleForEachStatus } from '../types'
5
+ import { LoadingSpinner } from './LoadingSpinner'
6
+ import { COLOR } from '../constants'
7
+
8
+ type Props = {
9
+ cssStyle?: ButtonStyleForEachStatus
10
+ duration?: number
11
+ isLoading?: boolean
12
+ isDisabled?: boolean
13
+ buttonType?: 'button' | 'submit' | 'reset'
14
+ onClick?: MouseEventHandler<HTMLButtonElement>
15
+ loading?: ReactNode
16
+ children?: ReactNode
17
+ }
18
+
19
+ export function BasicButton({
20
+ cssStyle,
21
+ duration = 0.3,
22
+ isLoading,
23
+ isDisabled,
24
+ buttonType = 'button',
25
+ onClick,
26
+ loading,
27
+ children,
28
+ }: Props) {
29
+ const baseStyle = isDisabled ? cssStyle?.disabled : cssStyle?.default
30
+
31
+ const currentCssStyle = {
32
+ textColor: COLOR.white,
33
+ backgroundColor: isDisabled ? COLOR.darkGray : COLOR.blue,
34
+ backgroundImage: 'none',
35
+ border: {
36
+ color: isDisabled ? COLOR.darkGray : COLOR.blue,
37
+ size: '1px',
38
+ radius: '.25rem',
39
+ },
40
+ boxShadow: '0 0 0 0 transparent',
41
+ ...baseStyle,
42
+ }
43
+
44
+ const hoverStyle = cssStyle?.hover || {}
45
+
46
+ const style = {
47
+ '--text-color': currentCssStyle.textColor,
48
+ '--background-color': currentCssStyle.backgroundColor,
49
+ '--background-image': currentCssStyle.backgroundImage,
50
+ '--radius-size': currentCssStyle.border?.radius,
51
+ '--button-shadow': `0 0 0 ${currentCssStyle.border?.size} ${currentCssStyle.border?.color} inset, ${currentCssStyle.boxShadow}`,
52
+ '--hover-text-color': hoverStyle.textColor || currentCssStyle.textColor,
53
+ '--hover-background-color':
54
+ hoverStyle.backgroundColor ||
55
+ (currentCssStyle.backgroundColor &&
56
+ `${currentCssStyle.backgroundColor}cc`),
57
+ '--hover-background-image':
58
+ hoverStyle.backgroundImage || currentCssStyle.backgroundImage,
59
+ '--hover-radius-size':
60
+ hoverStyle.border?.radius || currentCssStyle.border?.radius,
61
+ '--hover-button-shadow': `0 0 0 ${hoverStyle.border?.size || currentCssStyle.border?.size} ${hoverStyle.border?.color || currentCssStyle.border?.color} inset, ${hoverStyle.boxShadow || currentCssStyle.boxShadow}`,
62
+ '--duration': `${duration}s`,
63
+ } as CSSProperties
64
+
65
+ return (
66
+ <button
67
+ type={buttonType}
68
+ disabled={isDisabled || isLoading}
69
+ onClick={onClick}
70
+ style={style}
71
+ className="relative inline-flex w-max cursor-pointer items-center justify-center rounded-(--radius-size) border-none bg-(--background-color) px-8 py-4 leading-none text-(--text-color) shadow-(--button-shadow) transition-all duration-(--duration) enabled:hover:rounded-(--hover-radius-size) enabled:hover:bg-(--hover-background-color) enabled:hover:text-(--hover-text-color) enabled:hover:shadow-(--hover-button-shadow) disabled:pointer-events-none disabled:before:pointer-events-auto disabled:before:absolute disabled:before:inset-0 disabled:before:cursor-not-allowed disabled:before:content-['']"
72
+ >
73
+ {isLoading && (
74
+ <span className="absolute inset-0 m-auto flex max-h-[calc(100%-1.5rem)] items-center justify-center fill-current text-current [&_*]:max-h-full [&_*]:fill-current">
75
+ {loading ?? <LoadingSpinner />}
76
+ </span>
77
+ )}
78
+ <span className={isLoading ? 'invisible' : undefined}>{children}</span>
79
+ </button>
80
+ )
81
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties, ReactNode } from 'react'
4
+ import type { CheckBoxStyleForEachStatus } from '../types'
5
+ import { CheckIcon } from './icons/CheckIcon'
6
+ import { COLOR } from '../constants'
7
+
8
+ type Props = {
9
+ name: string
10
+ checked?: boolean
11
+ onChange?: (newValue: boolean) => void
12
+ isDisabled?: boolean
13
+ cssStyle?: CheckBoxStyleForEachStatus
14
+ isDisableAnimation?: boolean
15
+ check?: ReactNode
16
+ }
17
+
18
+ export function CheckBox({
19
+ name,
20
+ checked,
21
+ onChange,
22
+ isDisabled,
23
+ cssStyle,
24
+ isDisableAnimation,
25
+ check,
26
+ }: Props) {
27
+ const isChecked = checked ?? false
28
+ const baseStyle = isDisabled ? cssStyle?.disabled : cssStyle?.default
29
+
30
+ const currentCssStyle = {
31
+ textColor: isDisabled ? COLOR.darkGray : COLOR.blue,
32
+ backgroundColor: isDisabled ? COLOR.lightGray : COLOR.white,
33
+ border: {
34
+ color: isDisabled ? COLOR.darkGray : COLOR.blue,
35
+ size: '1px',
36
+ radius: '.25rem',
37
+ },
38
+ ...baseStyle,
39
+ }
40
+
41
+ const style = {
42
+ '--text-color': currentCssStyle.textColor,
43
+ '--border-color': currentCssStyle.border?.color,
44
+ '--border-size': currentCssStyle.border?.size,
45
+ '--radius-size': currentCssStyle.border?.radius,
46
+ '--background-color': currentCssStyle.backgroundColor,
47
+ '--duration': isDisableAnimation ? '0s' : '.3s',
48
+ } as CSSProperties
49
+
50
+ return (
51
+ <>
52
+ <style>
53
+ {
54
+ '@keyframes uiCheckPop{0%{scale:1}10%{scale:.8}50%{scale:1.1}100%{scale:1}}'
55
+ }
56
+ </style>
57
+ <button
58
+ type="button"
59
+ style={style}
60
+ aria-label={name}
61
+ aria-pressed={isChecked}
62
+ disabled={isDisabled}
63
+ onClick={(event) => {
64
+ event.stopPropagation()
65
+ if (!isDisabled) onChange?.(!isChecked)
66
+ }}
67
+ className={`relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-(--radius-size) border-none shadow-[0_0_0_var(--border-size)_var(--border-color)_inset] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--border-color) has-[input:disabled]:pointer-events-none has-[input:disabled]:before:pointer-events-auto has-[input:disabled]:before:absolute has-[input:disabled]:before:inset-0 has-[input:disabled]:before:cursor-not-allowed has-[input:disabled]:before:content-[''] ${isChecked ? 'animate-[uiCheckPop_var(--duration)_ease-out] bg-(--border-color)' : 'bg-(--background-color)'}`}
68
+ >
69
+ <input
70
+ type="checkbox"
71
+ name={name}
72
+ checked={isChecked}
73
+ disabled={isDisabled}
74
+ onChange={(event) => {
75
+ if (!isDisabled) onChange?.(event.target.checked)
76
+ }}
77
+ className="hidden"
78
+ />
79
+ <div
80
+ className={`flex h-4 w-4 items-center justify-center [&>*]:fill-current [&>*]:text-current ${isChecked ? 'text-(--background-color)' : 'text-(--border-color)'}`}
81
+ >
82
+ {check ?? <CheckIcon />}
83
+ </div>
84
+ </button>
85
+ </>
86
+ )
87
+ }
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import type { Option, CheckBoxStyleForEachStatus } from '../types'
4
+ import { MESSAGES } from '@geckou/ui-core'
5
+ import { useEffect, useRef, useState } from 'react'
6
+ import { LabeledCheckbox } from './LabeledCheckbox'
7
+ import { ErrorMessage } from './ErrorMessage'
8
+ import { COLOR } from '../constants'
9
+
10
+ type Props = {
11
+ name: string
12
+ options: Option[]
13
+ value?: string[]
14
+ onChange?: (newValue: string[]) => void
15
+ isDisabled?: boolean
16
+ isRequired?: boolean
17
+ cssStyle?: CheckBoxStyleForEachStatus
18
+ }
19
+
20
+ export function CheckBoxes({
21
+ name,
22
+ options,
23
+ value,
24
+ onChange,
25
+ isDisabled,
26
+ isRequired,
27
+ cssStyle,
28
+ }: Props) {
29
+ const [checkedValues, setCheckedValues] = useState<string[]>(value ?? [])
30
+ const [errorMessage, setErrorMessage] = useState('')
31
+
32
+ const validateInput = (newValues: string[]) =>
33
+ setErrorMessage(isRequired && newValues.length === 0 ? MESSAGES.required : '')
34
+
35
+ const isFirstRender = useRef(true)
36
+
37
+ useEffect(() => {
38
+ if (isFirstRender.current) {
39
+ isFirstRender.current = false
40
+ if (value?.length) validateInput(value)
41
+ return
42
+ }
43
+
44
+ if (value) setCheckedValues(value)
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- Vue 版(@geckou/ui-vue)同様、外部からの値変更時のみ同期する
46
+ }, [value])
47
+
48
+ const toggleValue = (optionValue: string, checked: boolean) => {
49
+ const newValues = options
50
+ .map((option) => String(option.value))
51
+ .filter((candidate) =>
52
+ candidate === optionValue ? checked : checkedValues.includes(candidate),
53
+ )
54
+
55
+ setCheckedValues(newValues)
56
+ validateInput(newValues)
57
+ onChange?.(newValues)
58
+ }
59
+
60
+ const errorColor = {
61
+ ...(cssStyle?.error ?? {}),
62
+ textColor: COLOR.white,
63
+ backgroundColor: COLOR.red,
64
+ border: {
65
+ color: COLOR.red,
66
+ size: '1px',
67
+ radius: '.25rem',
68
+ },
69
+ }
70
+
71
+ return (
72
+ <div className="relative flex flex-wrap gap-x-6 gap-y-4">
73
+ {options.map((option) => (
74
+ <LabeledCheckbox
75
+ key={option.value}
76
+ name={name}
77
+ label={option.label}
78
+ checked={checkedValues.includes(String(option.value))}
79
+ onChange={(checked) => toggleValue(String(option.value), checked)}
80
+ isDisabled={isDisabled}
81
+ cssStyle={cssStyle}
82
+ />
83
+ ))}
84
+ <ErrorMessage
85
+ errorMessages={errorMessage ? [errorMessage] : []}
86
+ cssStyle={{
87
+ textColor: errorColor.textColor,
88
+ backgroundColor: errorColor.backgroundColor,
89
+ }}
90
+ />
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties, ReactNode } from 'react'
4
+ import type { CheckBoxStyleForEachStatus } from '../types'
5
+ import { CheckIcon } from './icons/CheckIcon'
6
+
7
+ type Props = {
8
+ name: string
9
+ checked?: boolean
10
+ onChange?: (newValue: boolean) => void
11
+ isDisabled?: boolean
12
+ cssStyle?: CheckBoxStyleForEachStatus
13
+ isDisableAnimation?: boolean
14
+ check?: ReactNode
15
+ }
16
+
17
+ export function CheckButton({
18
+ name,
19
+ checked,
20
+ onChange,
21
+ isDisabled,
22
+ cssStyle = { default: {} },
23
+ isDisableAnimation,
24
+ check,
25
+ }: Props) {
26
+ const isChecked = checked ?? false
27
+ const currentCssStyle = isDisabled ? cssStyle.disabled : cssStyle.default
28
+
29
+ const style = {
30
+ '--border-color': currentCssStyle?.border?.color || 'blue',
31
+ '--border-size': currentCssStyle?.border?.size || '1px',
32
+ '--radius-size': currentCssStyle?.border?.radius || '0.25rem',
33
+ '--background-color': currentCssStyle?.backgroundColor || '#fff',
34
+ '--duration': isDisableAnimation ? '0s' : '.3s',
35
+ } as CSSProperties
36
+
37
+ return (
38
+ <>
39
+ <style>
40
+ {
41
+ '@keyframes uiCheckPop{0%{scale:1}10%{scale:.8}50%{scale:1.1}100%{scale:1}}'
42
+ }
43
+ </style>
44
+ <label
45
+ style={style}
46
+ className={`relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-(--radius-size) shadow-[0_0_0_var(--border-size)_var(--border-color)_inset] has-[input:disabled]:pointer-events-none has-[input:disabled]:before:pointer-events-auto has-[input:disabled]:before:absolute has-[input:disabled]:before:inset-0 has-[input:disabled]:before:cursor-not-allowed has-[input:disabled]:before:content-[''] has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-(--border-color) ${isChecked ? 'animate-[uiCheckPop_var(--duration)_ease-out] bg-(--border-color)' : 'bg-(--background-color)'}`}
47
+ >
48
+ <input
49
+ type="checkbox"
50
+ name={name}
51
+ checked={isChecked}
52
+ disabled={isDisabled}
53
+ onChange={(event) => {
54
+ if (!isDisabled) onChange?.(event.target.checked)
55
+ }}
56
+ className="sr-only"
57
+ />
58
+ <div
59
+ className={`flex h-4 w-4 items-center justify-center [&>*]:fill-current [&>*]:text-current ${isChecked ? 'text-(--background-color)' : 'text-(--border-color)'}`}
60
+ >
61
+ {check ?? <CheckIcon />}
62
+ </div>
63
+ </label>
64
+ </>
65
+ )
66
+ }
@@ -0,0 +1,154 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties } from 'react'
4
+ import type { DateObject, InputBoxStyleForEachStatus } from '../types'
5
+ import {
6
+ MESSAGES,
7
+ composeDateValue,
8
+ formatDateValue,
9
+ splitDate,
10
+ validateDateObject,
11
+ } from '@geckou/ui-core'
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { InputBox } from './InputBox'
14
+ import { ErrorMessage } from './ErrorMessage'
15
+ import { CalendarIcon } from './icons/CalendarIcon'
16
+ import { COLOR } from '../constants'
17
+
18
+ type Props = {
19
+ name: string
20
+ value?: string
21
+ onChange?: (newValue: string) => void
22
+ cssStyle?: InputBoxStyleForEachStatus
23
+ isDisabled?: boolean
24
+ isRequired?: boolean
25
+ minDate?: string
26
+ maxDate?: string
27
+ size?: 'small' | 'medium'
28
+ type?: 'date' | 'month'
29
+ }
30
+
31
+ export function DatePicker({
32
+ name,
33
+ value,
34
+ onChange,
35
+ cssStyle,
36
+ isDisabled,
37
+ isRequired,
38
+ minDate = '',
39
+ maxDate = '',
40
+ size = 'medium',
41
+ type = 'date',
42
+ }: Props) {
43
+ const [dateValue, setDateValue] = useState(() =>
44
+ value ? formatDateValue(value, type) : '',
45
+ )
46
+ const [dateObject, setDateObject] = useState<DateObject>(() =>
47
+ value ? splitDate(formatDateValue(value, type)) : { year: '', month: '', day: '' },
48
+ )
49
+ const [errorMessage, setErrorMessage] = useState('')
50
+
51
+ // prop が実際に変わったときだけ同期する(内部編集を上書きしないため)
52
+ const lastValueProp = useRef(value)
53
+
54
+ useEffect(() => {
55
+ if (lastValueProp.current === value) return
56
+ lastValueProp.current = value
57
+
58
+ const normalized = value ? formatDateValue(value, type) : ''
59
+ setDateValue(normalized)
60
+ setDateObject(splitDate(normalized))
61
+ }, [value, type])
62
+
63
+ const validateInput = (newValue: string) => {
64
+ if (!newValue && isRequired)
65
+ {return { isValid: false, message: MESSAGES.required }}
66
+ return { isValid: true, message: '' }
67
+ }
68
+
69
+ const validateObject = (object: DateObject) =>
70
+ validateDateObject(object, { type, isRequired })
71
+
72
+ const handleDateValueChange = (newValue: string) => {
73
+ setDateValue(newValue)
74
+ setDateObject(splitDate(newValue))
75
+ const { message } = validateInput(newValue)
76
+ setErrorMessage(message)
77
+ onChange?.(newValue)
78
+ }
79
+
80
+ const handleObjectChange = (key: keyof DateObject, newValue: string) => {
81
+ const next = { ...dateObject, [key]: newValue }
82
+ setDateObject(next)
83
+ const { isValid, message } = validateObject(next)
84
+ setErrorMessage(message)
85
+
86
+ if (isValid) {
87
+ const joined = composeDateValue(next, type)
88
+ setDateValue(joined)
89
+ onChange?.(joined)
90
+ }
91
+ }
92
+
93
+ const isSmall = size === 'small'
94
+ const textInputClass = `flex-none! ${isSmall ? 'px-[var(--sp-small,0.375rem)]! py-[var(--sp-min,0.1875rem)]! text-[length:var(--fs-small,0.6875rem)]' : 'p-[var(--sp-medium,0.75rem)]!'}`
95
+ const iconStyle = { '--icon-color': COLOR.blue } as CSSProperties
96
+
97
+ return (
98
+ <InputBox
99
+ cssStyle={cssStyle}
100
+ isDisabled={isDisabled}
101
+ isErrored={!!errorMessage}
102
+ className={`flex w-full items-center leading-none ${isSmall ? 'h-[calc(var(--bv,0.375rem)*5)] px-[var(--sp-min,0.1875rem)]' : 'h-[calc(var(--bv,0.375rem)*6)] px-[var(--sp-small,0.375rem)]'}`}
103
+ >
104
+ <div className="relative flex-none" style={iconStyle}>
105
+ <CalendarIcon
106
+ className={`pointer-events-none absolute inset-y-0 left-[var(--sp-small,0.375rem)] m-auto fill-(--icon-color) ${isSmall ? 'size-[var(--icon-small,0.9375rem)]' : 'size-[var(--icon-medium,1.125rem)]'}`}
107
+ />
108
+ <input
109
+ type={type}
110
+ name={name}
111
+ value={dateValue}
112
+ max={maxDate}
113
+ min={minDate}
114
+ required={isRequired}
115
+ disabled={isDisabled}
116
+ onChange={(event) => handleDateValueChange(event.target.value)}
117
+ className={`w-[calc(var(--icon-medium,1.125rem)+var(--sp-small,0.375rem)*2)]! cursor-pointer px-[var(--sp-small,0.375rem)]! opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:left-0 [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 ${isSmall ? 'py-[var(--sp-min,0.1875rem)]!' : 'py-[var(--sp-medium,0.75rem)]!'}`}
118
+ />
119
+ </div>
120
+ <input
121
+ value={dateObject.year}
122
+ placeholder="年"
123
+ maxLength={4}
124
+ type="text"
125
+ disabled={isDisabled}
126
+ onChange={(event) => handleObjectChange('year', event.target.value)}
127
+ className={`w-[calc(var(--sp-medium,0.75rem)*2+5ch)]! ${textInputClass}`}
128
+ />
129
+ /
130
+ <input
131
+ value={dateObject.month}
132
+ placeholder="月"
133
+ maxLength={2}
134
+ type="text"
135
+ disabled={isDisabled}
136
+ onChange={(event) => handleObjectChange('month', event.target.value)}
137
+ className={`w-[calc(var(--sp-medium,0.75rem)*2+3ch)]! ${textInputClass}`}
138
+ />
139
+ {type === 'date' && <span>/</span>}
140
+ {type === 'date' && (
141
+ <input
142
+ value={dateObject.day}
143
+ placeholder="日"
144
+ maxLength={2}
145
+ type="text"
146
+ disabled={isDisabled}
147
+ onChange={(event) => handleObjectChange('day', event.target.value)}
148
+ className={`w-[calc(var(--sp-medium,0.75rem)*2+3ch)]! ${textInputClass}`}
149
+ />
150
+ )}
151
+ <ErrorMessage errorMessages={errorMessage ? [errorMessage] : undefined} />
152
+ </InputBox>
153
+ )
154
+ }
@@ -0,0 +1,59 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties } from 'react'
4
+ import { useState } from 'react'
5
+ import { DatePicker } from './DatePicker'
6
+ import { COLOR } from '../constants'
7
+
8
+ type DateRange = {
9
+ startDate: string
10
+ endDate: string
11
+ }
12
+
13
+ type Props = {
14
+ isDisabled?: boolean
15
+ onChange?: (newValue: DateRange) => void
16
+ }
17
+
18
+ export function DateRangePicker({ isDisabled, onChange }: Props) {
19
+ const [startDate, setStartDate] = useState('')
20
+ const [endDate, setEndDate] = useState('')
21
+
22
+ const handleStartChange = (newValue: string) => {
23
+ setStartDate(newValue)
24
+ onChange?.({ startDate: newValue, endDate })
25
+ }
26
+
27
+ const handleEndChange = (newValue: string) => {
28
+ setEndDate(newValue)
29
+ onChange?.({ startDate, endDate: newValue })
30
+ }
31
+
32
+ const style = {
33
+ '--range-background-color': COLOR.white,
34
+ '--range-border-color': COLOR.gray,
35
+ } as CSSProperties
36
+
37
+ return (
38
+ <div
39
+ style={style}
40
+ className="flex bg-(--range-background-color) [&>*]:flex-auto [&>*]:rounded-none! [&>*:first-child]:rounded-s-[calc(var(--bv,0.375rem)/2)]! [&>*:last-child]:rounded-e-[calc(var(--bv,0.375rem)/2)]! [&>*:not(:last-child)]:border-e [&>*:not(:last-child)]:border-(--range-border-color)"
41
+ >
42
+ <DatePicker
43
+ value={startDate}
44
+ name="startDate"
45
+ isDisabled={isDisabled}
46
+ onChange={handleStartChange}
47
+ />
48
+ <div className="flex flex-none! items-center px-[var(--bv,0.375rem)]">
49
+
50
+ </div>
51
+ <DatePicker
52
+ value={endDate}
53
+ name="endDate"
54
+ isDisabled={isDisabled}
55
+ onChange={handleEndChange}
56
+ />
57
+ </div>
58
+ )
59
+ }