@proj-airi/ui 0.9.0-alpha.8 → 0.9.0-beta.1

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/README.md CHANGED
@@ -58,11 +58,12 @@ import { Button } from '@proj-airi/ui'
58
58
  * [TransitionVertical](src/components/Animations/TransitionVertical.vue)
59
59
  * [Form](src/components/Form)
60
60
  * [Checkbox](src/components/Form/Checkbox)
61
+ * [Select](src/components/Form/Select)
61
62
  * [Field](src/components/Form/Field)
62
63
  * [Input](src/components/Form/Input)
63
64
  * [Radio](src/components/Form/Radio)
64
65
  * [Range](src/components/Form/Range)
65
- * [Select](src/components/Form/Select)
66
+ * [ComboboxSelect](src/components/Form/Select)
66
67
  * [Textarea](src/components/Form/Textarea)
67
68
 
68
69
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@proj-airi/ui",
3
3
  "type": "module",
4
- "version": "0.9.0-alpha.8",
4
+ "version": "0.9.0-beta.1",
5
5
  "description": "A collection of UI components that used by Project AIRI",
6
6
  "author": {
7
7
  "name": "Moeru AI Project AIRI Team",
@@ -24,12 +24,12 @@
24
24
  "dependencies": {
25
25
  "@vueuse/core": "^14.2.1",
26
26
  "floating-vue": "^5.2.2",
27
- "reka-ui": "^2.8.2",
28
- "vue": "^3.5.25"
27
+ "reka-ui": "^2.9.2",
28
+ "vue": "^3.5.30"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@vue-macros/volar": "3.0.0-beta.8",
32
- "vue-tsc": "^3.1.8"
32
+ "vue-tsc": "^3.2.6"
33
33
  },
34
34
  "scripts": {
35
35
  "typecheck": "vue-tsc --noEmit"
@@ -1,12 +1,17 @@
1
1
  <script setup lang="ts">
2
2
  import { SwitchRoot, SwitchThumb } from 'reka-ui'
3
3
 
4
+ const props = defineProps<{
5
+ disabled?: boolean
6
+ }>()
7
+
4
8
  const modelValue = defineModel<boolean>({ required: true })
5
9
  </script>
6
10
 
7
11
  <template>
8
12
  <SwitchRoot
9
13
  v-model="modelValue"
14
+ :disabled="props.disabled"
10
15
  :class="[
11
16
  'duration-250 ease-in-out',
12
17
  'focus-within:outline-none',
@@ -14,8 +19,11 @@ const modelValue = defineModel<boolean>({ required: true })
14
19
  'is-interacting',
15
20
  'border-neutral-300 dark:border-neutral-700 data-[state=checked]:border-primary-200 data-[state=unchecked]:border-neutral-300 focus-within:border-neutral-800',
16
21
  'data-[state=checked]:bg-primary-400 data-[state=unchecked]:bg-neutral-300 data-[state=checked]:dark:bg-primary-400/80 dark:data-[state=unchecked]:bg-neutral-800',
22
+ 'data-[state=unchecked]:disabled:bg-neutral-400 data-[state=unchecked]:disabled:dark:bg-neutral-400/80',
23
+ 'data-[state=checked]:disabled:bg-primary-400 data-[state=checked]:disabled:dark:bg-primary-400/80',
17
24
  'relative h-7 w-12.5 rounded-full',
18
25
  'shadow-sm focus-within:shadow-none',
26
+ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
19
27
  ]"
20
28
  >
21
29
  <SwitchThumb
@@ -16,30 +16,84 @@ import {
16
16
  ComboboxTrigger,
17
17
  ComboboxViewport,
18
18
  } from 'reka-ui'
19
+ import { computed } from 'vue'
19
20
 
20
- const props = defineProps<{
21
- options: { groupLabel: string, children?: { label: string, value: T }[] }[]
21
+ interface ComboboxOptionItem<T extends AcceptableValue> {
22
+ label: string
23
+ value: T
24
+ description?: string
25
+ disabled?: boolean
26
+ icon?: string
27
+ }
28
+
29
+ interface ComboboxOptionGroupItem<T extends AcceptableValue> {
30
+ groupLabel?: string
31
+ children?: ComboboxOptionItem<T>[]
32
+ }
33
+
34
+ const props = withDefaults(defineProps<{
35
+ options: ComboboxOptionItem<T>[] | ComboboxOptionGroupItem<T>[]
22
36
  placeholder?: string
23
- }>()
37
+ disabled?: boolean
38
+ contentMinWidth?: string | number
39
+ contentWidth?: string | number
40
+ }>(), {
41
+ disabled: false,
42
+ })
24
43
 
25
44
  const modelValue = defineModel<T>({ required: false })
26
45
 
46
+ const normalizedOptions = computed<ComboboxOptionGroupItem<T>[]>(() => {
47
+ if (!props.options.length) {
48
+ return []
49
+ }
50
+
51
+ const [firstOption] = props.options
52
+ if ('value' in firstOption) {
53
+ return [
54
+ {
55
+ groupLabel: '',
56
+ children: props.options as ComboboxOptionItem<T>[],
57
+ },
58
+ ]
59
+ }
60
+
61
+ return props.options as ComboboxOptionGroupItem<T>[]
62
+ })
63
+
64
+ const flattenedOptions = computed<ComboboxOptionItem<T>[]>(() =>
65
+ normalizedOptions.value.flatMap(group => group.children ?? []),
66
+ )
67
+
27
68
  function toDisplayValue(value: T): string {
28
- const option = props.options.flatMap(group => group.children).find(option => option?.value === value)
29
- return option ? option.label : props.placeholder || ''
69
+ const option = flattenedOptions.value.find(option => option.value === value)
70
+ return option?.label ?? props.placeholder ?? ''
71
+ }
72
+
73
+ function toCssSize(value?: string | number): string | undefined {
74
+ if (value == null) {
75
+ return undefined
76
+ }
77
+
78
+ return typeof value === 'number' ? `${value}px` : value
30
79
  }
31
80
  </script>
32
81
 
33
82
  <template>
34
- <ComboboxRoot v-model="modelValue" :class="['relative', 'w-full']">
83
+ <ComboboxRoot
84
+ v-model="modelValue"
85
+ :disabled="props.disabled"
86
+ :class="['relative', 'w-full', 'h-fit']"
87
+ >
35
88
  <ComboboxAnchor
36
89
  :class="[
37
- 'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-10 gap-[5px] outline-none',
90
+ 'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-9 gap-[5px] outline-none',
38
91
  'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-200',
39
- 'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700',
92
+ 'bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-700',
40
93
  'border-neutral-200 dark:border-neutral-800 border-solid border-2 focus:border-primary-300 dark:focus:border-primary-400/50',
41
94
  'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black',
42
95
  'transition-colors duration-200 ease-in-out',
96
+ props.disabled ? 'cursor-not-allowed bg-neutral-100 opacity-60 dark:bg-neutral-900' : 'cursor-pointer',
43
97
  ]"
44
98
  >
45
99
  <ComboboxInput
@@ -48,6 +102,7 @@ function toDisplayValue(value: T): string {
48
102
  'text-neutral-700 dark:text-neutral-200',
49
103
  'transition-colors duration-200 ease-in-out',
50
104
  ]"
105
+ :disabled="props.disabled"
51
106
  :placeholder="props.placeholder"
52
107
  :display-value="(val) => toDisplayValue(val)"
53
108
  />
@@ -76,12 +131,15 @@ function toDisplayValue(value: T): string {
76
131
  // Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
77
132
  // Read more at: https://github.com/moeru-ai/airi/issues/1136
78
133
  'z-[10010]',
79
- 'w-full min-w-[160px] overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
134
+ 'w-full overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
80
135
  'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
81
136
  'bg-white dark:bg-neutral-900',
82
137
  'border-neutral-200 dark:border-neutral-800 border-solid border-2 focus:border-neutral-300 dark:focus:border-neutral-600',
83
138
  ]"
84
- :style="{ width: 'var(--reka-combobox-trigger-width)' }"
139
+ :style="{
140
+ width: toCssSize(props.contentWidth) ?? 'var(--reka-combobox-trigger-width)',
141
+ minWidth: toCssSize(props.contentMinWidth) ?? '160px',
142
+ }"
85
143
  >
86
144
  <ComboboxViewport :class="['p-[2px]', 'max-h-50dvh', 'overflow-y-auto']">
87
145
  <ComboboxEmpty
@@ -90,19 +148,22 @@ function toDisplayValue(value: T): string {
90
148
  'text-xs text-neutral-700 dark:text-neutral-200',
91
149
  'transition-colors duration-200 ease-in-out',
92
150
  ]"
93
- />
151
+ >
152
+ <slot name="empty" />
153
+ </ComboboxEmpty>
94
154
 
95
155
  <template
96
- v-for="(group, index) in options"
97
- :key="group.groupLabel"
156
+ v-for="(group, groupIndex) in normalizedOptions"
157
+ :key="group.groupLabel || `group-${groupIndex}`"
98
158
  >
99
159
  <ComboboxGroup :class="['overflow-x-hidden']">
100
160
  <ComboboxSeparator
101
- v-if="index !== 0"
161
+ v-if="groupIndex !== 0"
102
162
  :class="['m-[5px]', 'h-[1px]', 'bg-neutral-400']"
103
163
  />
104
164
 
105
165
  <ComboboxLabel
166
+ v-if="group.groupLabel"
106
167
  :class="[
107
168
  'px-[25px] text-xs leading-[25px]',
108
169
  'text-neutral-500 dark:text-neutral-400',
@@ -113,26 +174,70 @@ function toDisplayValue(value: T): string {
113
174
  </ComboboxLabel>
114
175
 
115
176
  <ComboboxItem
116
- v-for="option in group.children"
117
- :key="option.label"
177
+ v-for="(option, optionIndex) in group.children || []"
178
+ :key="`${group.groupLabel || groupIndex}-${option.label}-${optionIndex}`"
118
179
  :text-value="option.label"
119
180
  :value="option.value"
181
+ :disabled="option.disabled"
120
182
  :class="[
121
- 'leading-normal rounded-lg flex items-center h-8 pr-[0.5rem] pl-[1.5rem] relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
183
+ 'leading-normal rounded-lg grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 min-h-8 px-2 relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
122
184
  'data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-800',
123
185
  'text-sm text-neutral-700 dark:text-neutral-200 data-[disabled]:text-neutral-400 dark:data-[disabled]:text-neutral-600 data-[highlighted]:text-grass1',
124
186
  'transition-colors duration-200 ease-in-out',
125
- 'cursor-pointer',
187
+ option.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
126
188
  ]"
127
189
  >
128
190
  <ComboboxItemIndicator
129
- :class="['absolute', 'left-0', 'w-[25px]', 'inline-flex', 'items-center', 'justify-center', 'opacity-30']"
191
+ :class="[
192
+ 'col-start-1 row-start-1',
193
+ 'inline-flex items-center justify-center',
194
+ 'w-[1rem]',
195
+ 'opacity-30',
196
+ 'text-current',
197
+ ]"
130
198
  >
131
- <div i-solar:alt-arrow-right-outline />
199
+ <div i-solar:alt-arrow-right-outline class="size-4" />
132
200
  </ComboboxItemIndicator>
133
- <span :class="['line-clamp-1', 'overflow-hidden', 'text-ellipsis', 'whitespace-nowrap']">
134
- {{ option.label }}
135
- </span>
201
+
202
+ <div :class="['col-start-2', 'min-w-0', 'flex', 'items-center', 'gap-2', 'py-1']">
203
+ <slot
204
+ name="option"
205
+ v-bind="{ option }"
206
+ >
207
+ <span
208
+ v-if="option.icon"
209
+ :class="[
210
+ 'size-4 shrink-0',
211
+ 'text-current',
212
+ option.icon,
213
+ ]"
214
+ />
215
+
216
+ <div :class="['min-w-0', 'flex', 'flex-1', 'flex-col']">
217
+ <span
218
+ :class="[
219
+ 'line-clamp-1',
220
+ 'overflow-hidden',
221
+ 'text-ellipsis',
222
+ 'whitespace-nowrap',
223
+ ]"
224
+ >
225
+ {{ option.label }}
226
+ </span>
227
+
228
+ <span
229
+ v-if="option.description"
230
+ :class="[
231
+ 'line-clamp-2',
232
+ 'text-xs',
233
+ 'text-neutral-500 dark:text-neutral-400',
234
+ ]"
235
+ >
236
+ {{ option.description }}
237
+ </span>
238
+ </div>
239
+ </slot>
240
+ </div>
136
241
  </ComboboxItem>
137
242
  </ComboboxGroup>
138
243
  </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { Combobox } from '../combobox'
3
+
4
+ const props = defineProps<{
5
+ options?: {
6
+ label: string
7
+ value: string | number
8
+ description?: string
9
+ disabled?: boolean
10
+ icon?: string
11
+ }[]
12
+ placeholder?: string
13
+ disabled?: boolean
14
+ title?: string
15
+ layout?: 'horizontal' | 'vertical'
16
+ contentMinWidth?: string | number
17
+ contentWidth?: string | number
18
+ }>()
19
+
20
+ const modelValue = defineModel<string | number>({ required: false })
21
+ </script>
22
+
23
+ <template>
24
+ <Combobox
25
+ v-model="modelValue"
26
+ :options="[{ groupLabel: '', children: props.options }]"
27
+ :disabled="props.disabled"
28
+ :content-min-width="props.contentMinWidth"
29
+ :content-width="props.contentWidth"
30
+ :placeholder="props.placeholder"
31
+ >
32
+ <template
33
+ v-if="$slots.option"
34
+ #option="{ option }"
35
+ >
36
+ <slot
37
+ name="option"
38
+ v-bind="{ option }"
39
+ />
40
+ </template>
41
+
42
+ <template
43
+ v-if="$slots.empty"
44
+ #empty
45
+ >
46
+ <slot name="empty" />
47
+ </template>
48
+ </Combobox>
49
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as ComboboxOption } from './combobox-option.vue'
2
+ export { default as ComboboxSelect } from './combobox-select.vue'
@@ -1,17 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import { Checkbox } from '../checkbox'
3
3
 
4
- const props = defineProps<{
4
+ const props = withDefaults(defineProps<{
5
5
  label?: string
6
6
  description?: string
7
- }>()
7
+ disabled?: boolean
8
+ /** Controls whether the switch is placed on the left or right side of the label. */
9
+ placement?: 'left' | 'right'
10
+ }>(), {
11
+ placement: 'right',
12
+ })
8
13
 
9
14
  const modelValue = defineModel<boolean>({ required: true })
10
15
  </script>
11
16
 
12
17
  <template>
13
18
  <label class="flex flex-col gap-4">
14
- <div class="flex flex-row items-center gap-2">
19
+ <div :class="['flex items-center gap-2', props.placement === 'left' ? 'flex-row-reverse' : 'flex-row']">
15
20
  <div class="flex-1">
16
21
  <div class="flex items-center gap-1 text-sm font-medium">
17
22
  <slot name="label">
@@ -24,7 +29,7 @@ const modelValue = defineModel<boolean>({ required: true })
24
29
  </slot>
25
30
  </div>
26
31
  </div>
27
- <Checkbox v-model="modelValue" />
32
+ <Checkbox v-model="modelValue" :disabled="props.disabled" />
28
33
  </div>
29
34
  </label>
30
35
  </template>
@@ -1,14 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { Select } from '../select'
2
+ import { ComboboxSelect } from '../combobox-select'
3
3
 
4
4
  const props = withDefaults(defineProps<{
5
5
  label: string
6
6
  description?: string
7
- options?: { label: string, value: string | number }[]
7
+ options?: {
8
+ label: string
9
+ value: string | number
10
+ description?: string
11
+ disabled?: boolean
12
+ icon?: string
13
+ }[]
8
14
  placeholder?: string
9
15
  disabled?: boolean
10
16
  layout?: 'horizontal' | 'vertical'
11
17
  selectClass?: string | string[]
18
+ contentMinWidth?: string | number
19
+ contentWidth?: string | number
12
20
  }>(), {
13
21
  layout: 'horizontal',
14
22
  })
@@ -21,45 +29,59 @@ const modelValue = defineModel<string>({ required: false })
21
29
  <div
22
30
  :class="[
23
31
  'items-center',
24
- 'justify-center',
25
- props.layout === 'horizontal' ? 'grid grid-cols-3 gap-2' : 'grid grid-cols-2 gap-2',
32
+ props.layout === 'horizontal' ? 'grid grid-cols-4 gap-2' : 'grid grid-rows-2 gap-2',
26
33
  ]"
27
34
  >
28
35
  <div
29
36
  :class="[
30
37
  'w-full',
31
- props.layout === 'horizontal' ? 'col-span-2' : 'row-span-1',
38
+ props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
32
39
  ]"
33
40
  >
34
- <div :class="['flex', 'items-center', 'gap-1', 'break-words', 'text-sm', 'font-medium']">
41
+ <div :class="['flex', 'items-center', 'gap-1', 'break-words', 'text-sm', 'font-medium', 'text-left']">
35
42
  <slot name="label">
36
43
  {{ props.label }}
37
44
  </slot>
38
45
  </div>
39
- <div :class="['break-words', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
46
+ <div :class="['break-words', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400', 'text-left']">
40
47
  <slot name="description">
41
48
  {{ props.description }}
42
49
  </slot>
43
50
  </div>
44
51
  </div>
45
52
  <slot>
46
- <Select
53
+ <ComboboxSelect
47
54
  v-model="modelValue"
48
55
  :options="props.options?.filter(option => option.label && option.value) || []"
49
56
  :placeholder="props.placeholder"
50
57
  :disabled="props.disabled"
58
+ :content-min-width="props.contentMinWidth"
59
+ :content-width="props.contentWidth"
51
60
  :title="label"
52
61
  :class="[
53
62
  ...(props.selectClass
54
63
  ? (typeof props.selectClass === 'string' ? [props.selectClass] : props.selectClass)
55
64
  : []),
56
- props.layout === 'horizontal' ? 'col-span-1' : 'row-span-2',
65
+ props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
57
66
  ]"
58
67
  >
59
- <template #default="{ value }">
60
- {{ props.options?.find(option => option.value === value)?.label || props.placeholder }}
68
+ <template
69
+ v-if="$slots.option"
70
+ #option="{ option }"
71
+ >
72
+ <slot
73
+ name="option"
74
+ v-bind="{ option }"
75
+ />
61
76
  </template>
62
- </Select>
77
+
78
+ <template
79
+ v-if="$slots.empty"
80
+ #empty
81
+ >
82
+ <slot name="empty" />
83
+ </template>
84
+ </ComboboxSelect>
63
85
  </slot>
64
86
  </div>
65
87
  </label>
@@ -36,9 +36,9 @@ const modelValue = defineModel<number>({ required: true })
36
36
  <div :class="['flex', 'flex-row', 'items-center', 'gap-2']">
37
37
  <Range
38
38
  v-model="modelValue"
39
- :min="min || 0"
40
- :max="max || 1"
41
- :step="step || 0.01"
39
+ :min="min ?? 0"
40
+ :max="max ?? 1"
41
+ :step="step ?? 0.01"
42
42
  :class="['w-full']"
43
43
  />
44
44
  </div>
@@ -1,7 +1,7 @@
1
1
  export { default as FieldCheckbox } from './field-checkbox.vue'
2
+ export { default as FieldCombobox } from './field-combobox.vue'
2
3
  export { default as FieldInput } from './field-input.vue'
3
4
  export { default as FieldKeyValues } from './field-key-values.vue'
4
5
  export { default as FieldRange } from './field-range.vue'
5
- export { default as FieldSelect } from './field-select.vue'
6
6
  export { default as FieldTextArea } from './field-text-area.vue'
7
7
  export { default as FieldValues } from './field-values.vue'
@@ -1,5 +1,6 @@
1
1
  export * from './checkbox'
2
2
  export * from './combobox'
3
+ export * from './combobox-select'
3
4
  export * from './field'
4
5
  export * from './input'
5
6
  export * from './radio'
@@ -1,10 +1,14 @@
1
1
  <script setup lang="ts">
2
+ import { useSlots } from 'vue'
3
+
2
4
  import BasicInputFile from './basic-input-file.vue'
3
5
 
4
6
  defineProps<{
5
7
  accept?: string
6
8
  multiple?: boolean
7
9
  }>()
10
+
11
+ const slots = useSlots()
8
12
  </script>
9
13
 
10
14
  <template>
@@ -28,7 +32,9 @@ defineProps<{
28
32
  :multiple="multiple"
29
33
  >
30
34
  <template #default="{ isDragging }">
35
+ <slot v-if="slots.default" :is-dragging="isDragging" />
31
36
  <div
37
+ v-else
32
38
  class="flex flex-col items-center"
33
39
  :class="[
34
40
  isDragging ? 'text-primary-500 dark:text-primary-400' : 'text-neutral-400 dark:text-neutral-500',
@@ -56,7 +56,8 @@ function handleInput(e: Event) {
56
56
  :min="scaledMin"
57
57
  :max="scaledMax"
58
58
  :step="scaledStep"
59
- class="slider-progress form_input-round-range"
59
+ :disabled="props.disabled"
60
+ :class="['slider-progress', 'form_input-round-range', props.disabled ? 'opacity-60 pointer-events-none' : '']"
60
61
  @input="handleInput"
61
62
  >
62
63
  </template>
@@ -115,6 +116,20 @@ https://toughengineer.github.io/demo/slider-styler*/
115
116
  outline: none;
116
117
  }
117
118
 
119
+ .form_input-round-range:disabled {
120
+ opacity: 0.6;
121
+ cursor: not-allowed;
122
+ }
123
+
124
+ .form_input-round-range:disabled::-webkit-slider-thumb,
125
+ .form_input-round-range:disabled::-webkit-slider-runnable-track,
126
+ .form_input-round-range:disabled::-moz-range-thumb,
127
+ .form_input-round-range:disabled::-moz-range-track,
128
+ .form_input-round-range:disabled::-ms-thumb,
129
+ .form_input-round-range:disabled::-ms-track {
130
+ cursor: not-allowed;
131
+ }
132
+
118
133
  /*webkit*/
119
134
  .form_input-round-range::-webkit-slider-thumb {
120
135
  appearance: none;
@@ -1,2 +1,2 @@
1
- export { default as Option } from './option.vue'
1
+ export { default as SelectOption } from './select-option.vue'
2
2
  export { default as Select } from './select.vue'
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts" generic="T extends AcceptableValue">
2
+ import type { AcceptableValue } from 'reka-ui'
3
+
4
+ import {
5
+ SelectItem,
6
+ SelectItemIndicator,
7
+ SelectItemText,
8
+ } from 'reka-ui'
9
+
10
+ interface SelectOptionItem<T extends AcceptableValue> {
11
+ label: string
12
+ value: T
13
+ description?: string
14
+ disabled?: boolean
15
+ icon?: string
16
+ }
17
+
18
+ const props = defineProps<{
19
+ option: SelectOptionItem<T>
20
+ }>()
21
+ </script>
22
+
23
+ <template>
24
+ <SelectItem
25
+ :value="props.option.value"
26
+ :disabled="props.option.disabled"
27
+ :text-value="props.option.label"
28
+ :class="[
29
+ 'leading-normal rounded-lg grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 min-h-8 px-2 relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
30
+ 'data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-800',
31
+ 'text-sm text-neutral-700 dark:text-neutral-200 data-[disabled]:text-neutral-400 dark:data-[disabled]:text-neutral-600',
32
+ 'transition-colors duration-200 ease-in-out',
33
+ props.option.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
34
+ ]"
35
+ >
36
+ <SelectItemIndicator
37
+ :class="[
38
+ 'col-start-1 row-start-1',
39
+ 'inline-flex items-center justify-center',
40
+ 'w-[1rem]',
41
+ 'opacity-30',
42
+ 'text-current',
43
+ ]"
44
+ >
45
+ <div i-solar:alt-arrow-right-outline class="size-4" />
46
+ </SelectItemIndicator>
47
+
48
+ <SelectItemText :class="['sr-only']">
49
+ {{ props.option.label }}
50
+ </SelectItemText>
51
+
52
+ <div :class="['col-start-2', 'min-w-0', 'flex', 'items-center', 'gap-2', 'py-1']">
53
+ <slot v-bind="{ option: props.option }">
54
+ <span
55
+ v-if="props.option.icon"
56
+ :class="[
57
+ 'size-4 shrink-0',
58
+ 'text-current',
59
+ props.option.icon,
60
+ ]"
61
+ />
62
+
63
+ <div :class="['min-w-0 flex flex-1 flex-col']">
64
+ <span
65
+ :class="[
66
+ 'line-clamp-1',
67
+ 'overflow-hidden',
68
+ 'text-ellipsis',
69
+ 'whitespace-nowrap',
70
+ ]"
71
+ >
72
+ {{ props.option.label }}
73
+ </span>
74
+
75
+ <span
76
+ v-if="props.option.description"
77
+ :class="[
78
+ 'line-clamp-2',
79
+ 'text-xs',
80
+ 'text-neutral-500 dark:text-neutral-400',
81
+ ]"
82
+ >
83
+ {{ props.option.description }}
84
+ </span>
85
+ </div>
86
+ </slot>
87
+ </div>
88
+ </SelectItem>
89
+ </template>
@@ -1,31 +1,242 @@
1
- <script setup lang="ts">
2
- import { provide, ref } from 'vue'
1
+ <script setup lang="ts" generic="T extends AcceptableValue">
2
+ import type { AcceptableValue } from 'reka-ui'
3
3
 
4
- import { Combobox } from '../combobox'
4
+ import {
5
+ SelectArrow,
6
+ SelectContent,
7
+ SelectGroup,
8
+ SelectIcon,
9
+ SelectLabel,
10
+ SelectPortal,
11
+ SelectRoot,
12
+ SelectSeparator,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ SelectViewport,
16
+ } from 'reka-ui'
17
+ import { computed } from 'vue'
5
18
 
6
- const props = defineProps<{
7
- options?: { label: string, value: string | number }[]
19
+ import SelectOption from './select-option.vue'
20
+
21
+ interface SelectOptionItem<T extends AcceptableValue> {
22
+ label: string
23
+ value: T
24
+ description?: string
25
+ disabled?: boolean
26
+ icon?: string
27
+ }
28
+
29
+ interface SelectOptionGroupItem<T extends AcceptableValue> {
30
+ groupLabel?: string
31
+ children?: SelectOptionItem<T>[]
32
+ }
33
+
34
+ const props = withDefaults(defineProps<{
35
+ options: SelectOptionItem<T>[] | SelectOptionGroupItem<T>[]
8
36
  placeholder?: string
9
37
  disabled?: boolean
10
- title?: string
11
- layout?: 'horizontal' | 'vertical'
12
- }>()
38
+ by?: string | ((a: T, b: T) => boolean)
39
+ contentMinWidth?: string | number
40
+ contentWidth?: string | number
41
+ shape?: 'rounded' | 'default'
42
+ variant?: 'blurry' | 'default'
43
+ }>(), {
44
+ placeholder: 'Select an option',
45
+ disabled: false,
46
+ by: undefined,
47
+ contentMinWidth: 160,
48
+ contentWidth: undefined,
49
+ shape: 'default',
50
+ variant: 'default',
51
+ })
13
52
 
14
- const show = ref(false)
15
- const modelValue = defineModel<string | number>({ required: false })
53
+ const modelValue = defineModel<T>({ required: false })
16
54
 
17
- function selectOption(value: string | number) {
18
- modelValue.value = value
19
- }
55
+ const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
56
+ if (!props.options.length) {
57
+ return []
58
+ }
59
+
60
+ const [firstOption] = props.options
61
+ if ('value' in firstOption) {
62
+ return [
63
+ {
64
+ groupLabel: '',
65
+ children: props.options as SelectOptionItem<T>[],
66
+ },
67
+ ]
68
+ }
69
+
70
+ return props.options as SelectOptionGroupItem<T>[]
71
+ })
72
+
73
+ const flattenedOptions = computed<SelectOptionItem<T>[]>(() =>
74
+ normalizedOptions.value.flatMap(group => group.children ?? []),
75
+ )
20
76
 
21
- function handleHide() {
22
- show.value = false
77
+ const selectedOption = computed<SelectOptionItem<T> | undefined>(() =>
78
+ flattenedOptions.value.find(option => isSelectedOption(option.value, modelValue.value)),
79
+ )
80
+
81
+ function isSelectedOption(a: T, b: T | undefined): boolean {
82
+ if (b == null) {
83
+ return false
84
+ }
85
+
86
+ if (typeof props.by === 'function') {
87
+ return props.by(a, b)
88
+ }
89
+
90
+ if (typeof props.by === 'string') {
91
+ return (a as Record<string, unknown> | null)?.[props.by] === (b as Record<string, unknown> | null)?.[props.by]
92
+ }
93
+
94
+ return a === b
23
95
  }
24
96
 
25
- provide('selectOption', selectOption)
26
- provide('hide', handleHide)
97
+ function toCssSize(value?: string | number): string | undefined {
98
+ if (value == null) {
99
+ return undefined
100
+ }
101
+
102
+ return typeof value === 'number' ? `${value}px` : value
103
+ }
27
104
  </script>
28
105
 
29
106
  <template>
30
- <Combobox v-model="modelValue" :default-value="modelValue" :options="[{ groupLabel: '', children: props.options }]" />
107
+ <SelectRoot
108
+ v-model="modelValue"
109
+ :by="props.by"
110
+ :disabled="props.disabled"
111
+ >
112
+ <SelectTrigger
113
+ :class="[
114
+ 'group',
115
+ 'w-full inline-flex items-center justify-between border px-1 leading-none h-fit gap-[5px] outline-none',
116
+ props.shape === 'rounded' ? 'rounded-full' : 'rounded-lg',
117
+ 'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
118
+ props.variant === 'default' ? 'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
119
+ props.variant === 'blurry' ? 'bg-neutral-50/70 dark:bg-neutral-800/70 disabled:bg-neutral-100 hover:bg-neutral-100 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-800' : '',
120
+ props.variant === 'blurry' ? 'backdrop-blur-md' : '',
121
+ 'border-2 border-solid focus:border-primary-300 dark:focus:border-primary-400/50',
122
+ props.variant === 'default' ? 'border-neutral-200 dark:border-neutral-800' : '',
123
+ props.variant === 'blurry' ? 'border-neutral-100/60 dark:border-neutral-800/30' : '',
124
+ 'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black/10 dark:focus:shadow-black/30',
125
+ 'transition-colors duration-200 ease-in-out',
126
+ props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
127
+ ]"
128
+ >
129
+ <div :class="['min-w-0 flex-1 text-left']">
130
+ <slot
131
+ v-if="$slots.value"
132
+ name="value"
133
+ v-bind="{ option: selectedOption, value: modelValue, placeholder: props.placeholder }"
134
+ >
135
+ <span
136
+ :class="[
137
+ 'block truncate',
138
+ selectedOption ? 'text-neutral-700 dark:text-neutral-200' : 'text-neutral-400 dark:text-neutral-500',
139
+ ]"
140
+ >
141
+ {{ selectedOption?.label ?? props.placeholder }}
142
+ </span>
143
+ </slot>
144
+ <SelectValue
145
+ v-else
146
+ v-model="modelValue"
147
+ :placeholder="props.placeholder"
148
+ />
149
+ </div>
150
+ <SelectIcon as-child>
151
+ <div
152
+ i-solar:alt-arrow-down-linear
153
+ :class="[
154
+ 'h-4 w-4 shrink-0',
155
+ 'text-neutral-700 dark:text-neutral-200',
156
+ 'transition-transform duration-200 ease-in-out',
157
+ 'group-data-[state=open]:rotate-180',
158
+ ]"
159
+ />
160
+ </SelectIcon>
161
+ </SelectTrigger>
162
+
163
+ <SelectPortal>
164
+ <SelectContent
165
+ position="popper"
166
+ side="bottom"
167
+ align="start"
168
+ :side-offset="4"
169
+ :avoid-collisions="true"
170
+ :class="[
171
+ // NOTICE: DialogContent/DialogOverlay use z-[9999], and DrawerContent uses z-[1000].
172
+ // SelectContent must render above these layers so that dropdowns inside
173
+ // Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
174
+ // Read more at: https://github.com/moeru-ai/airi/issues/1136
175
+ 'z-[10010]',
176
+ 'overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
177
+ 'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
178
+ 'bg-white dark:bg-neutral-900',
179
+ 'border-neutral-200 dark:border-neutral-800 border-solid border-2',
180
+ ]"
181
+ :style="{
182
+ width: toCssSize(props.contentWidth) ?? 'var(--reka-select-trigger-width)',
183
+ minWidth: toCssSize(props.contentMinWidth),
184
+ }"
185
+ >
186
+ <SelectViewport
187
+ :class="[
188
+ 'p-[2px]',
189
+ 'max-h-50dvh',
190
+ 'overflow-y-auto',
191
+ ]"
192
+ >
193
+ <template
194
+ v-for="(group, groupIndex) in normalizedOptions"
195
+ :key="group.groupLabel || `group-${groupIndex}`"
196
+ >
197
+ <SelectGroup :class="['overflow-x-hidden']">
198
+ <SelectSeparator
199
+ v-if="groupIndex !== 0"
200
+ :class="['m-[5px]', 'h-[1px]', 'bg-neutral-200 dark:bg-neutral-800']"
201
+ />
202
+
203
+ <SelectLabel
204
+ v-if="group.groupLabel"
205
+ :class="[
206
+ 'px-[25px] text-xs leading-[25px]',
207
+ 'text-neutral-500 dark:text-neutral-400',
208
+ 'transition-colors duration-200 ease-in-out',
209
+ ]"
210
+ >
211
+ {{ group.groupLabel }}
212
+ </SelectLabel>
213
+
214
+ <SelectOption
215
+ v-for="(option, optionIndex) in group.children || []"
216
+ :key="`${group.groupLabel || groupIndex}-${option.label}-${optionIndex}`"
217
+ :option="option"
218
+ >
219
+ <template
220
+ v-if="$slots.option"
221
+ #default="{ option: slotOption }"
222
+ >
223
+ <slot
224
+ name="option"
225
+ v-bind="{ option: slotOption }"
226
+ />
227
+ </template>
228
+ </SelectOption>
229
+ </SelectGroup>
230
+ </template>
231
+ </SelectViewport>
232
+
233
+ <SelectArrow
234
+ :class="[
235
+ 'fill-white dark:fill-neutral-900',
236
+ 'stroke-neutral-200 dark:stroke-neutral-800',
237
+ ]"
238
+ />
239
+ </SelectContent>
240
+ </SelectPortal>
241
+ </SelectRoot>
31
242
  </template>
@@ -1,10 +1,10 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends string | number">
2
2
  import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
3
3
  import { computed } from 'vue'
4
4
 
5
5
  interface SelectTabOption {
6
6
  label: string
7
- value: string | number
7
+ value: T
8
8
  description?: string
9
9
  icon?: string
10
10
  }
@@ -20,7 +20,7 @@ const props = withDefaults(defineProps<{
20
20
  size: 'md',
21
21
  })
22
22
 
23
- const modelValue = defineModel<string | number>({ required: true })
23
+ const modelValue = defineModel<T>({ required: true })
24
24
 
25
25
  const activeIndex = computed(() => props.options.findIndex(option => option.value === modelValue.value))
26
26
  const itemCount = computed(() => props.options.length || 1)
@@ -28,8 +28,8 @@ const isDisabled = computed(() => props.disabled || props.readonly)
28
28
 
29
29
  const sizeClasses = computed(() =>
30
30
  props.size === 'sm'
31
- ? ['py-2', 'px-3', 'text-xs', 'rounded-md']
32
- : ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md'],
31
+ ? ['py-2', 'px-3', 'text-xs', 'rounded-md', 'min-w-24']
32
+ : ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md', 'min-w-32'],
33
33
  )
34
34
 
35
35
  const rootStyle = computed(() => ({
@@ -49,7 +49,7 @@ const rootStyle = computed(() => ({
49
49
  :class="[
50
50
  'select-tab',
51
51
  'is-interacting',
52
- 'relative', 'flex', 'w-full', 'items-stretch', 'rounded-lg',
52
+ 'relative', 'flex', 'items-stretch', 'rounded-lg',
53
53
  'overflow-hidden',
54
54
  'bg-white-400/6', 'dark:bg-neutral-950/70',
55
55
  'transition-[border-color,box-shadow,opacity]', 'duration-200', 'ease-out',
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch } from 'vue'
3
3
 
4
- const props = defineProps<{
4
+ const props = withDefaults(defineProps<{
5
5
  defaultHeight?: string
6
- }>()
6
+ submitOnEnter?: boolean
7
+ }>(), {
8
+ submitOnEnter: true,
9
+ })
7
10
 
8
11
  const events = defineEmits<{
9
12
  (event: 'submit', message: string): void
@@ -18,6 +21,9 @@ const textareaRef = ref<HTMLTextAreaElement>()
18
21
  const textareaHeight = ref('auto')
19
22
 
20
23
  function onKeyDown(e: KeyboardEvent) {
24
+ if (!props.submitOnEnter)
25
+ return
26
+
21
27
  if (e.code === 'Enter' && !e.shiftKey) { // just block Enter is enough, Shift+Enter by default generates a newline
22
28
  e.preventDefault()
23
29
  events('submit', input.value)