@geckou/ui-vue 0.3.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -0
  3. package/dist/assets/scss/functions.scss +6 -0
  4. package/dist/assets/scss/mixin.scss +33 -0
  5. package/dist/components/ArticleList/Card/Artistic.vue +310 -0
  6. package/dist/components/ArticleList/Card/Entertainment.vue +244 -0
  7. package/dist/components/ArticleList/Card/Gallery.vue +254 -0
  8. package/dist/components/ArticleList/Card/Grid.vue +247 -0
  9. package/dist/components/ArticleList/Card/News.vue +150 -0
  10. package/dist/components/ArticleList/Card/Rounded.vue +154 -0
  11. package/dist/components/ArticleList/Card/Row.vue +167 -0
  12. package/dist/components/ArticleList/Card/Simple.vue +232 -0
  13. package/dist/components/ArticleList/Card/Standard.vue +187 -0
  14. package/dist/components/ArticleList/Card/Tile.vue +171 -0
  15. package/dist/components/ArticleList/GenericArticleList.vue +143 -0
  16. package/dist/components/ArticleList/List/Artistic.vue +24 -0
  17. package/dist/components/ArticleList/List/Entertainment.vue +24 -0
  18. package/dist/components/ArticleList/List/Gallery.vue +24 -0
  19. package/dist/components/ArticleList/List/Grid.vue +216 -0
  20. package/dist/components/ArticleList/List/News.vue +24 -0
  21. package/dist/components/ArticleList/List/Rounded.vue +24 -0
  22. package/dist/components/ArticleList/List/Row.vue +24 -0
  23. package/dist/components/ArticleList/List/Simple.vue +24 -0
  24. package/dist/components/ArticleList/List/Standard.vue +24 -0
  25. package/dist/components/ArticleList/List/Tile.vue +24 -0
  26. package/dist/components/ArticleList/Parts/AuthorInfo.vue +100 -0
  27. package/dist/components/ArticleList/Parts/CardContainer.vue +23 -0
  28. package/dist/components/ArticleList/Parts/CardHeading.vue +43 -0
  29. package/dist/components/ArticleList/Parts/CategoryList.vue +41 -0
  30. package/dist/components/ArticleList/Parts/ExcerptText.vue +43 -0
  31. package/dist/components/ArticleList/Parts/MetadataList.vue +120 -0
  32. package/dist/components/ArticleList/Parts/NoImage.vue +36 -0
  33. package/dist/components/ArticleList/Parts/PostedDate.vue +41 -0
  34. package/dist/components/ArticleList/Parts/TagList.vue +41 -0
  35. package/dist/components/ArticleList/Parts/ThumbnailImage.vue +85 -0
  36. package/dist/components/BasicButton.vue +143 -0
  37. package/dist/components/CheckBox.vue +140 -0
  38. package/dist/components/CheckBoxes.vue +100 -0
  39. package/dist/components/CheckButton.vue +127 -0
  40. package/dist/components/DatePicker.vue +234 -0
  41. package/dist/components/DateRangePicker.vue +85 -0
  42. package/dist/components/DateSelector.vue +273 -0
  43. package/dist/components/DropdownUi.vue +118 -0
  44. package/dist/components/ErrorMessage.vue +61 -0
  45. package/dist/components/Icon/CalendarIcon.vue +8 -0
  46. package/dist/components/Icon/CheckIcon.vue +8 -0
  47. package/dist/components/Icon/CloseIcon.vue +8 -0
  48. package/dist/components/Icon/Folder.vue +8 -0
  49. package/dist/components/Icon/Image.vue +14 -0
  50. package/dist/components/Icon/KeyboardArrowDownIcon.vue +8 -0
  51. package/dist/components/Icon/Tag.vue +8 -0
  52. package/dist/components/InputBox.vue +134 -0
  53. package/dist/components/InputGroup.vue +41 -0
  54. package/dist/components/LabeledCheckbox.vue +82 -0
  55. package/dist/components/LabeledFieldset.vue +41 -0
  56. package/dist/components/LoadingSpinner.vue +56 -0
  57. package/dist/components/ModalBox.vue +192 -0
  58. package/dist/components/PopupBox.vue +93 -0
  59. package/dist/components/RadioButtons.vue +163 -0
  60. package/dist/components/SelectBox.vue +166 -0
  61. package/dist/components/SlideDownUi.vue +123 -0
  62. package/dist/components/TabUI.vue +123 -0
  63. package/dist/components/TextArea.vue +115 -0
  64. package/dist/components/TextBox.vue +111 -0
  65. package/dist/components/TextButton.vue +47 -0
  66. package/dist/components/ToggleButton.vue +179 -0
  67. package/dist/const/index.ts +2 -0
  68. package/dist/const/list-theme.ts +62 -0
  69. package/dist/index.ts +126 -0
  70. package/dist/scripts/form-validation-manager.ts +63 -0
  71. package/dist/scripts/utils.ts +36 -0
  72. package/dist/types/custom.d.ts +1 -0
  73. package/dist/types/index.d.ts +93 -0
  74. package/dist/types/vue-shim.d.ts +5 -0
  75. package/package.json +44 -0
@@ -0,0 +1,273 @@
1
+ <script setup lang="ts">
2
+ import type { Ref } from 'vue'
3
+ import { ref, computed, onBeforeUnmount, watch, watchEffect } from 'vue'
4
+ import { daysInMonth, splitDate } from '@geckou/ui-core'
5
+ import { FormValidationManager } from '../scripts/form-validation-manager'
6
+ import InputBox from '../components/InputBox.vue'
7
+ import KeyboardArrowDownIcon from '../components/Icon/KeyboardArrowDownIcon.vue'
8
+ import TextButton from '../components/TextButton.vue'
9
+
10
+ const emit = defineEmits<{ (e: 'update:modelValue', newValue: string): void }>()
11
+
12
+ type Date = {
13
+ year: string
14
+ month: string
15
+ day?: string
16
+ }
17
+ const props = withDefaults(defineProps<{
18
+ name: string
19
+ modelValue: string
20
+ isRequired?: boolean
21
+ formValidationManager?: FormValidationManager | null
22
+ type?: 'date' | 'month'
23
+ }>(), {
24
+ formValidationManager: null,
25
+ type : 'date',
26
+ })
27
+
28
+ const birthday: Ref<Date> = ref({
29
+ year : '',
30
+ month: '',
31
+ day : '',
32
+ })
33
+
34
+ const yearsOptions = computed(() => {
35
+ const today = new Date()
36
+ const maxYear = today.getFullYear() - 100
37
+ const currentYear = today.getFullYear() - 14
38
+ const years = Array.from({ length: currentYear - maxYear + 1 }, (_, i) => (maxYear + i).toString())
39
+ return years.map(year => ({ label: year, value: year }))
40
+ })
41
+
42
+ const monthOptions = computed(() => {
43
+ return Array.from({ length: 12 }, (_, i) => {
44
+ const month = (i + 1).toString()
45
+ return {
46
+ label: month,
47
+ value: month.padStart(2, '0'),
48
+ }
49
+ })
50
+ })
51
+
52
+ const daysInSelectedMonth = computed(() => {
53
+ const month = Number(birthday.value?.month)
54
+ if (!month) return 31
55
+
56
+ // 年が未選択のときは閏年を含む最大日数になるよう 2000 年を使う
57
+ const year = Number(birthday.value?.year) || 2000
58
+ return daysInMonth(year, month)
59
+ })
60
+
61
+ const dayOptions = computed(() => {
62
+ const days = []
63
+ const daysInMouth = daysInSelectedMonth.value
64
+
65
+ for (let day = 1; day <= daysInMouth; day++) {
66
+ const dayString = day.toString()
67
+ days.push(dayString)
68
+ }
69
+
70
+ return days.map(day => ({
71
+ label: day,
72
+ value: String(day).padStart(2, '0'),
73
+ }))
74
+ })
75
+
76
+ const openDropdown = (e: MouseEvent) => {
77
+ const target = e.currentTarget as HTMLElement
78
+ const select = target.firstElementChild as HTMLSelectElement
79
+ if (select) {
80
+ select.click()
81
+ }
82
+ }
83
+
84
+ const selectItem = (event: Event, key: 'year' | 'day' | 'month') => {
85
+ const target = event.target as HTMLSelectElement
86
+ const { value } = target
87
+ birthday.value = { ...birthday.value, [key]: value }
88
+ }
89
+
90
+ // 月や年を変えて日が存在しなくなった場合は、その月の末日に丸める
91
+ watch(daysInSelectedMonth, days => {
92
+ const day = Number(birthday.value.day)
93
+ if (day && day > days) birthday.value = { ...birthday.value, day: String(days).padStart(2, '0') }
94
+ })
95
+
96
+ const EMPTY_BIRTHDAY = { year: '', month: '', day: '' }
97
+
98
+ // value が空に戻されたときもリセットする(以前は前の選択が残っていた)
99
+ watchEffect(() => {
100
+ birthday.value = props.modelValue
101
+ ? splitDate(props.modelValue)
102
+ : { ...EMPTY_BIRTHDAY }
103
+ })
104
+
105
+ const setValid = (isValid: boolean): void => {
106
+ if (props.formValidationManager) props.formValidationManager.setValid(props.name, isValid)
107
+ }
108
+
109
+ setValid(!props.isRequired)
110
+
111
+ watch(() => birthday.value, newValue => {
112
+ const isFilled = Boolean(newValue.year && newValue.month && (props.type === 'month' || newValue.day))
113
+ const isEmpty = !newValue.year && !newValue.month && !newValue.day
114
+
115
+ setValid(props.isRequired ? isFilled : isFilled || isEmpty)
116
+
117
+ if (isEmpty) emit('update:modelValue', '')
118
+ else if (!isFilled) return
119
+ else {
120
+ const parts = props.type === 'month'
121
+ ? [newValue.year, newValue.month]
122
+ : [newValue.year, newValue.month, newValue.day]
123
+ emit('update:modelValue', parts.filter(Boolean).join('-'))
124
+ }
125
+ }, { deep: true })
126
+
127
+ // アンマウント後も無効判定が残らないように登録を解除する
128
+ onBeforeUnmount(() => props.formValidationManager?.remove(props.name))
129
+ </script>
130
+
131
+ <template>
132
+ <InputBox :class="$style.date_selector">
133
+ <div
134
+ :class="[$style.selector_wrapper]"
135
+ @click="openDropdown($event)"
136
+ >
137
+ <select
138
+ :value="birthday.year"
139
+ :class="$style.year"
140
+ @change="selectItem($event, 'year')"
141
+ >
142
+ <option
143
+ disabled
144
+ selected
145
+ value=""
146
+ >
147
+
148
+ </option>
149
+ <option
150
+ v-for="year in yearsOptions"
151
+ :key="year.value"
152
+ :value="year.value"
153
+ >
154
+ {{ year.label }}
155
+ </option>
156
+ </select>
157
+ <KeyboardArrowDownIcon />
158
+ </div>
159
+ <div
160
+ :class="[$style.selector_wrapper]"
161
+ @click="openDropdown($event)"
162
+ >
163
+ <select
164
+ :value="birthday.month"
165
+ @change="selectItem($event, 'month')"
166
+ >
167
+ <option
168
+ disabled
169
+ selected
170
+ value=""
171
+ >
172
+
173
+ </option>
174
+ <option
175
+ v-for="month in monthOptions"
176
+ :key="month.value"
177
+ :value="month.value"
178
+ >
179
+ {{ month.label }}
180
+ </option>
181
+ </select>
182
+ <KeyboardArrowDownIcon />
183
+ </div>
184
+ <div
185
+ v-if="type === 'date'"
186
+ :class="[$style.selector_wrapper]"
187
+ @click="openDropdown($event)"
188
+ >
189
+ <select
190
+ :value="birthday.day"
191
+ @change="selectItem($event, 'day')"
192
+ >
193
+ <option
194
+ disabled
195
+ selected
196
+ value=""
197
+ >
198
+
199
+ </option>
200
+ <option
201
+ v-for="day in dayOptions"
202
+ :key="day.value"
203
+ :value="day.value"
204
+ >
205
+ {{ day.label }}
206
+ </option>
207
+ </select>
208
+ <KeyboardArrowDownIcon />
209
+ </div>
210
+ <TextButton
211
+ text="削除"
212
+ variant="caution"
213
+ :class="$style.delete_button"
214
+ @click="birthday = { year: '', month: '', day: '' }"
215
+ />
216
+ </InputBox>
217
+ </template>
218
+
219
+ <style lang="scss" module>
220
+ @use '../assets/scss/mixin' as *;
221
+
222
+ .date_selector {
223
+ width : max-content;
224
+ display : flex;
225
+ align-items: center;
226
+ position : relative;
227
+
228
+ > * {
229
+ &:not(:last-of-type) {
230
+ &:after {
231
+ content: '/';
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ .selector_wrapper {
238
+ position: relative;
239
+ width : max-content;
240
+
241
+ > svg {
242
+ @include icon($color: var(--link-color));
243
+ position : absolute;
244
+ margin : auto;
245
+ top : 0;
246
+ right : var(--sp-small);
247
+ bottom : 0;
248
+ pointer-events: none;
249
+ }
250
+
251
+ &:has(select:focus) {
252
+ svg {
253
+ rotate: 180deg;
254
+ }
255
+ }
256
+
257
+ > select {
258
+ width : calc(3ch + (var(--sp-medium) * 2) + var(--icon-medium));
259
+ padding : var(--sp-medium);
260
+ padding-inline-end: calc((var(--sp-small) * 2) + var(--icon-medium));
261
+ cursor : pointer;
262
+
263
+ &.year {
264
+ width: calc(5ch + (var(--sp-medium) * 2) + var(--icon-medium));
265
+ }
266
+ }
267
+ }
268
+
269
+ .delete_button {
270
+ margin-inline: var(--sp-medium);
271
+ font-size : var(--fs-small);
272
+ }
273
+ </style>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUpdated } from 'vue'
3
+ import IconChevronDown from '../components/Icon/KeyboardArrowDownIcon.vue'
4
+ withDefaults(defineProps<{
5
+ isHiddenArrow?: boolean,
6
+ contentAlignment?: 'left' | 'center' | 'right',
7
+ isDisabled?: boolean
8
+ contentsWidth?: string
9
+ }>(), {
10
+ isHiddenArrow : false,
11
+ contentAlignment: 'left',
12
+ isDisabled : false,
13
+ contentsWidth : 'auto',
14
+ })
15
+
16
+ const isContentsOpened = ref(false)
17
+ const contents = ref<HTMLElement | null>(null)
18
+ const contentsHeight = ref(0)
19
+ const toggleBox = () => isContentsOpened.value = !isContentsOpened.value
20
+ const closeDropDown = () => isContentsOpened.value = false
21
+
22
+ const updateContentsHeight = () => {
23
+ const contentsValue = contents.value
24
+ if (contentsValue) {
25
+ contentsHeight.value = contentsValue.clientHeight
26
+ }
27
+ }
28
+
29
+ onMounted(()=> updateContentsHeight())
30
+ onUpdated(() => updateContentsHeight())
31
+ defineExpose({ isContentsOpened })
32
+ </script>
33
+
34
+ <template>
35
+ <div
36
+ v-click-outside="closeDropDown"
37
+ :class="$style.drop_down_box"
38
+ >
39
+ <button
40
+ :class="$style.button"
41
+ :disabled="isDisabled"
42
+ type="button"
43
+ :style="{
44
+ '--trigger-color': isDisabled || !$slots.contents ? 'var(--text-color)' : 'var(--link-color)',
45
+ cursor: isDisabled || !$slots.contents ? 'auto' : 'pointer',
46
+ }"
47
+ @click.prevent="toggleBox"
48
+ >
49
+ <slot name="trigger" />
50
+ <IconChevronDown
51
+ v-if="!(isHiddenArrow || !$slots.contents || isDisabled)"
52
+ :class="[$style.icon, { [$style.open]: isContentsOpened }]"
53
+ />
54
+ </button>
55
+ <div
56
+ v-if="$slots.contents"
57
+ :class="$style.contents"
58
+ :style="{
59
+ boxShadow: isContentsOpened ? 'var(--box-shadow)' : 'none',
60
+ blockSize: isContentsOpened ? `${contentsHeight}px` : 0,
61
+ inlineSize: contentsWidth,
62
+ right: contentAlignment === 'right' ? 'calc(var(--bv) * -0.5)' : 'auto',
63
+ left: contentAlignment === 'left' ? 'calc(var(--bv) * -0.5)' : 'auto',
64
+ zIndex: '2',
65
+ }"
66
+ >
67
+ <div
68
+ ref="contents"
69
+ @click="closeDropDown"
70
+ >
71
+ <slot name="contents" />
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <style lang="scss" module>
78
+ @use '../assets/scss/mixin' as *;
79
+
80
+ .drop_down_box {
81
+ position: relative;
82
+ }
83
+
84
+ .button {
85
+ display : flex;
86
+ align-items: center;
87
+ inline-size: 100%;
88
+ block-size : 100%;
89
+ gap : var(--sp-small);
90
+ color : var(--trigger-color);
91
+ }
92
+
93
+ .icon {
94
+ @include icon($size: var(--icon-small));
95
+ flex : 0 0 auto;
96
+ transition: all .1s;
97
+
98
+ &.open {
99
+ transform: rotate(180deg);
100
+ }
101
+ }
102
+
103
+ .contents {
104
+ border-radius: var(--radius-small);
105
+ position : absolute;
106
+ top : calc(100% - var(--sp-min));
107
+ transition : block-size .1s;
108
+ overflow : hidden;
109
+ cursor : pointer;
110
+
111
+ > div {
112
+ max-block-size : calc(var(--bv) * 48);
113
+ min-inline-size : calc(var(--bv) * 20);
114
+ background-color: var(--white);
115
+ overflow : auto;
116
+ }
117
+ }
118
+ </style>
@@ -0,0 +1,61 @@
1
+ <script lang="ts" setup>
2
+ import { COLOR } from '../const'
3
+ const { red: cautionColor } = COLOR
4
+
5
+ defineProps<{
6
+ cssStyle?: {
7
+ textColor: string | undefined
8
+ backgroundColor: string | undefined
9
+ }
10
+ errorMessages?: Array<string>
11
+ }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div
16
+ v-show="errorMessages && errorMessages.length"
17
+ :class="$style.error_messages"
18
+ :style="{
19
+ '--error-text-color': cssStyle?.textColor || '#fff',
20
+ '--error-background-color': cssStyle?.backgroundColor || cautionColor,
21
+ }"
22
+ >
23
+ <span
24
+ v-for="message in errorMessages"
25
+ :key="message"
26
+ >
27
+ {{ message }}
28
+ </span>
29
+ </div>
30
+ </template>
31
+
32
+ <style lang="scss" module>
33
+ :is(.error_messages) {
34
+ inline-size: max-content;
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: .5rem;
38
+ padding: .5rem .75rem;
39
+ font-size: small;
40
+ color: var(--error-text-color);
41
+ background-color: var(--error-background-color);
42
+ box-shadow: 0 0 .5rem .25rem #33333322;
43
+ border-radius: .25rem;
44
+ line-height: 1;
45
+ position: absolute;
46
+ inset-block-start: calc(100% + .25rem);
47
+
48
+ &::before {
49
+ --tail-size: .85rem;
50
+ content: '';
51
+ display: block;
52
+ background-color: var(--error-background-color);
53
+ inline-size: var(--tail-size);
54
+ block-size: calc(var(--tail-size) * .625);
55
+ clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
56
+ position: absolute;
57
+ bottom: calc(100% - 1px);
58
+ inset-inline-start: .85rem;
59
+ }
60
+ }
61
+ </style>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 -960 960 960"
5
+ >
6
+ <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-80H200v80Zm280 240q-17 0-28.5-11.5T440-440q0-17 11.5-28.5T480-480q17 0 28.5 11.5T520-440q0 17-11.5 28.5T480-400Zm-160 0q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480q17 0 28.5 11.5T360-440q0 17-11.5 28.5T320-400Zm320 0q-17 0-28.5-11.5T600-440q0-17 11.5-28.5T640-480q17 0 28.5 11.5T680-440q0 17-11.5 28.5T640-400ZM480-240q-17 0-28.5-11.5T440-280q0-17 11.5-28.5T480-320q17 0 28.5 11.5T520-280q0 17-11.5 28.5T480-240Zm-160 0q-17 0-28.5-11.5T280-280q0-17 11.5-28.5T320-320q17 0 28.5 11.5T360-280q0 17-11.5 28.5T320-240Zm320 0q-17 0-28.5-11.5T600-280q0-17 11.5-28.5T640-320q17 0 28.5 11.5T680-280q0 17-11.5 28.5T640-240Z" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 -960 960 960"
5
+ >
6
+ <path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 -960 960 960"
5
+ >
6
+ <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" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 0 24 24"
5
+ >
6
+ <path d="M20 5h-9.586L8.707 3.293A.997.997 0 0 0 8 3H4c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V7c0-1.103-.897-2-2-2z" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 0 24 24"
5
+ >
6
+ <circle
7
+ cx="7.499"
8
+ cy="9.5"
9
+ r="1.5"
10
+ />
11
+ <path d="m10.499 14-1.5-2-3 4h12l-4.5-6z" />
12
+ <path d="M19.999 4h-16c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-16 14V6h16l.002 12H3.999z" />
13
+ </svg>
14
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 -960 960 960"
5
+ >
6
+ <path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ viewBox="0 0 24 24"
5
+ >
6
+ <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8 8a2 2 0 0 0 2.828 0l7.172-7.172a2 2 0 0 0 0-2.828l-8-8zM7 9a2 2 0 1 1 .001-4.001A2 2 0 0 1 7 9z" />
7
+ </svg>
8
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ StateVariation,
4
+ InputBoxStyle,
5
+ InputBoxStyleForEachStatus,
6
+ } from '../types'
7
+ import {
8
+ ref,
9
+ computed,
10
+ onMounted,
11
+ onUnmounted,
12
+ watch,
13
+ } from 'vue'
14
+ import { INPUT_BOX_DEFAULT_STYLES } from '../const'
15
+
16
+ const props = defineProps<{
17
+ cssStyle?: InputBoxStyleForEachStatus
18
+ isErrored?: boolean
19
+ isDisabled?: boolean
20
+ }>()
21
+
22
+ const inputBox = ref<HTMLElement | null>(null)
23
+
24
+ const cssStylesUsed = computed<InputBoxStyleForEachStatus>(() => ({
25
+ ...INPUT_BOX_DEFAULT_STYLES,
26
+ ...(props.cssStyle ?? {}),
27
+ }))
28
+
29
+ const currentCssStyle = computed<InputBoxStyle>(() => cssStylesUsed.value[currentState.value as StateVariation] ?? cssStylesUsed.value.default)
30
+ const currentState = ref<StateVariation>('default')
31
+
32
+ const checkElementState = (el: Element | null | undefined) => {
33
+ if (!el) return currentState.value = 'default'
34
+ if (el.matches(':disabled')) return currentState.value = 'disabled'
35
+ if (el.matches(':focus')) return currentState.value = 'focus'
36
+ if (el.tagName.toLowerCase() === 'select') return currentState.value = ((el as HTMLSelectElement).required && !(el as HTMLSelectElement).value) ? 'error' : 'valid'
37
+ if (props.isErrored || el.matches(':invalid')) return currentState.value = 'error'
38
+ if (el.matches(':valid') && el.matches(':not(:placeholder-shown)') && el.matches(':not(:invalid)')) return currentState.value = 'valid'
39
+ return currentState.value = 'default'
40
+ }
41
+
42
+ const updateState = () => {
43
+ const el = inputBox.value?.querySelector('input, textarea, select')
44
+ checkElementState(el)
45
+ }
46
+
47
+ onMounted(() => {
48
+ if (!inputBox.value) return
49
+ const observer = new MutationObserver(updateState)
50
+ observer.observe(inputBox.value, { childList: true, subtree: true })
51
+ inputBox.value.addEventListener('focusin', updateState, true)
52
+ inputBox.value.addEventListener('blur', updateState, true)
53
+ })
54
+
55
+ onUnmounted(() => {
56
+ if (!inputBox.value) return
57
+ inputBox.value.removeEventListener('focusin', updateState, true)
58
+ inputBox.value.removeEventListener('blur', updateState, true)
59
+ })
60
+
61
+ watch(() => props.isDisabled, () => currentState.value = props.isDisabled ? 'disabled' : 'default', { immediate: true })
62
+
63
+ // isErrored の変化は focus / blur を伴わないため、明示的に再判定する
64
+ // (以前は次に focus か blur が起きるまでエラー配色にならなかった)
65
+ watch(() => props.isErrored, () => {
66
+ if (props.isDisabled) return
67
+ updateState()
68
+ })
69
+ </script>
70
+
71
+ <template>
72
+ <div
73
+ ref="inputBox"
74
+ :class="$style.input_box"
75
+ :style="{
76
+ '--text-color': currentCssStyle.textColor,
77
+ '--placeholder-color': currentCssStyle.placeholderColor,
78
+ '--background-color': currentCssStyle.backgroundColor,
79
+ '--border-style': `0 0 0 ${ currentCssStyle.border?.size || '1px' } ${ currentCssStyle.border?.color } inset`,
80
+ '--radius-size': currentCssStyle.border?.radius || '.25rem',
81
+ '--box-shadow': currentCssStyle.boxShadow || '0 0 0 0 rgba(0, 0, 0, 0)',
82
+ }"
83
+ >
84
+ <slot />
85
+ </div>
86
+ </template>
87
+
88
+ <style lang="scss" module>
89
+ :is(.input_box) {
90
+ display: inline-flex;
91
+ max-inline-size: 100%;
92
+ background-color: var(--background-color);
93
+ box-shadow:
94
+ var(--border-style),
95
+ var(--box-shadow);
96
+ border-radius: var(--radius-size);
97
+ color: var(--text-color);
98
+ position: relative;
99
+
100
+ input,
101
+ textarea,
102
+ select {
103
+ inline-size: 100%;
104
+ padding: 1rem;
105
+ background-color: transparent;
106
+ border: none;
107
+ outline: none;
108
+ appearance: none;
109
+ }
110
+
111
+ *::placeholder {
112
+ color: var(--placeholder-color);
113
+ font-weight: normal;
114
+ }
115
+
116
+ *:focus,
117
+ *:focus:not(:focus-visible) {
118
+ outline: none;
119
+ }
120
+
121
+ &:has(> *:disabled) {
122
+ pointer-events: none !important;
123
+ position: relative;
124
+
125
+ &::before {
126
+ content: '';
127
+ position: absolute;
128
+ inset: 0;
129
+ pointer-events: all;
130
+ cursor: not-allowed !important;
131
+ }
132
+ }
133
+ }
134
+ </style>
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <div :class="$style.input_group">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <style lang="scss" module>
8
+ @use '../assets/scss/mixin' as *;
9
+
10
+ .input_group {
11
+ display: inline-flex;
12
+ gap : 1px;
13
+
14
+ @include media('mobile') {
15
+ flex-wrap: wrap;
16
+ }
17
+
18
+ @mixin childItem {
19
+ flex : 1 1 auto;
20
+ border-radius: 0;
21
+
22
+ &:first-child:not(:only-child) {
23
+ border-top-left-radius : var(--radius-small);
24
+ border-bottom-left-radius: var(--radius-small);
25
+ }
26
+
27
+ &:last-child:not(:only-child) {
28
+ border-top-right-radius : var(--radius-small);
29
+ border-bottom-right-radius: var(--radius-small);
30
+ }
31
+ }
32
+
33
+ > * {
34
+ @include childItem;
35
+ }
36
+
37
+ > button {
38
+ flex: 0 0 auto;
39
+ }
40
+ }
41
+ </style>