@proj-airi/ui 0.9.0-alpha.21 → 0.9.0-alpha.23

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.21",
4
+ "version": "0.9.0-alpha.23",
5
5
  "description": "A collection of UI components that used by Project AIRI",
6
6
  "author": {
7
7
  "name": "Moeru AI Project AIRI Team",
@@ -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',
@@ -16,6 +21,7 @@ const modelValue = defineModel<boolean>({ required: true })
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',
17
22
  'relative h-7 w-12.5 rounded-full',
18
23
  'shadow-sm focus-within:shadow-none',
24
+ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
19
25
  ]"
20
26
  >
21
27
  <SwitchThumb
@@ -20,6 +20,8 @@ import {
20
20
  const props = defineProps<{
21
21
  options: { groupLabel: string, children?: { label: string, value: T }[] }[]
22
22
  placeholder?: string
23
+ contentMinWidth?: string | number
24
+ contentWidth?: string | number
23
25
  }>()
24
26
 
25
27
  const modelValue = defineModel<T>({ required: false })
@@ -28,6 +30,14 @@ function toDisplayValue(value: T): string {
28
30
  const option = props.options.flatMap(group => group.children).find(option => option?.value === value)
29
31
  return option ? option.label : props.placeholder || ''
30
32
  }
33
+
34
+ function toCssSize(value?: string | number): string | undefined {
35
+ if (value == null) {
36
+ return undefined
37
+ }
38
+
39
+ return typeof value === 'number' ? `${value}px` : value
40
+ }
31
41
  </script>
32
42
 
33
43
  <template>
@@ -76,12 +86,15 @@ function toDisplayValue(value: T): string {
76
86
  // Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
77
87
  // Read more at: https://github.com/moeru-ai/airi/issues/1136
78
88
  'z-[10010]',
79
- 'w-full min-w-[160px] overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
89
+ 'w-full overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
80
90
  'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
81
91
  'bg-white dark:bg-neutral-900',
82
92
  'border-neutral-200 dark:border-neutral-800 border-solid border-2 focus:border-neutral-300 dark:focus:border-neutral-600',
83
93
  ]"
84
- :style="{ width: 'var(--reka-combobox-trigger-width)' }"
94
+ :style="{
95
+ width: toCssSize(props.contentWidth) ?? 'var(--reka-combobox-trigger-width)',
96
+ minWidth: toCssSize(props.contentMinWidth) ?? '160px',
97
+ }"
85
98
  >
86
99
  <ComboboxViewport :class="['p-[2px]', 'max-h-50dvh', 'overflow-y-auto']">
87
100
  <ComboboxEmpty
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { provide, ref } from 'vue'
3
+
4
+ import { Combobox } from '../combobox'
5
+
6
+ const props = defineProps<{
7
+ options?: { label: string, value: string | number }[]
8
+ placeholder?: string
9
+ disabled?: boolean
10
+ title?: string
11
+ layout?: 'horizontal' | 'vertical'
12
+ contentMinWidth?: string | number
13
+ contentWidth?: string | number
14
+ }>()
15
+
16
+ const show = ref(false)
17
+ const modelValue = defineModel<string | number>({ required: false })
18
+
19
+ function selectOption(value: string | number) {
20
+ modelValue.value = value
21
+ }
22
+
23
+ function handleHide() {
24
+ show.value = false
25
+ }
26
+
27
+ provide('selectOption', selectOption)
28
+ provide('hide', handleHide)
29
+ </script>
30
+
31
+ <template>
32
+ <Combobox
33
+ v-model="modelValue"
34
+ :default-value="modelValue"
35
+ :options="[{ groupLabel: '', children: props.options }]"
36
+ :content-min-width="props.contentMinWidth"
37
+ :content-width="props.contentWidth"
38
+ />
39
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as ComboboxOption } from './combobox-option.vue'
2
+ export { default as ComboboxSelect } from './combobox-select.vue'
@@ -4,6 +4,7 @@ import { Checkbox } from '../checkbox'
4
4
  const props = defineProps<{
5
5
  label?: string
6
6
  description?: string
7
+ disabled?: boolean
7
8
  }>()
8
9
 
9
10
  const modelValue = defineModel<boolean>({ required: true })
@@ -24,7 +25,7 @@ const modelValue = defineModel<boolean>({ required: true })
24
25
  </slot>
25
26
  </div>
26
27
  </div>
27
- <Checkbox v-model="modelValue" />
28
+ <Checkbox v-model="modelValue" :disabled="props.disabled" />
28
29
  </div>
29
30
  </label>
30
31
  </template>
@@ -1,5 +1,5 @@
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
@@ -42,7 +42,7 @@ const modelValue = defineModel<string>({ required: false })
42
42
  </div>
43
43
  </div>
44
44
  <slot>
45
- <Select
45
+ <ComboboxSelect
46
46
  v-model="modelValue"
47
47
  :options="props.options?.filter(option => option.label && option.value) || []"
48
48
  :placeholder="props.placeholder"
@@ -58,7 +58,7 @@ const modelValue = defineModel<string>({ required: false })
58
58
  <template #default="{ value }">
59
59
  {{ props.options?.find(option => option.value === value)?.label || props.placeholder }}
60
60
  </template>
61
- </Select>
61
+ </ComboboxSelect>
62
62
  </slot>
63
63
  </div>
64
64
  </label>
@@ -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,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-[1.25rem]',
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,241 @@
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
+ variant?: 'blurry' | 'default'
42
+ }>(), {
43
+ placeholder: 'Select an option',
44
+ disabled: false,
45
+ by: undefined,
46
+ contentMinWidth: 160,
47
+ contentWidth: undefined,
48
+ variant: 'default',
49
+ })
13
50
 
14
- const show = ref(false)
15
- const modelValue = defineModel<string | number>({ required: false })
51
+ const modelValue = defineModel<T>({ required: false })
16
52
 
17
- function selectOption(value: string | number) {
18
- modelValue.value = value
19
- }
53
+ const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
54
+ if (!props.options.length) {
55
+ return []
56
+ }
57
+
58
+ const [firstOption] = props.options
59
+ if ('value' in firstOption) {
60
+ return [
61
+ {
62
+ groupLabel: '',
63
+ children: props.options as SelectOptionItem<T>[],
64
+ },
65
+ ]
66
+ }
67
+
68
+ return props.options as SelectOptionGroupItem<T>[]
69
+ })
70
+
71
+ const flattenedOptions = computed<SelectOptionItem<T>[]>(() =>
72
+ normalizedOptions.value.flatMap(group => group.children ?? []),
73
+ )
20
74
 
21
- function handleHide() {
22
- show.value = false
75
+ const selectedOption = computed<SelectOptionItem<T> | undefined>(() =>
76
+ flattenedOptions.value.find(option => isSelectedOption(option.value, modelValue.value)),
77
+ )
78
+
79
+ function isSelectedOption(a: T, b: T | undefined): boolean {
80
+ if (b == null) {
81
+ return false
82
+ }
83
+
84
+ if (typeof props.by === 'function') {
85
+ return props.by(a, b)
86
+ }
87
+
88
+ if (typeof props.by === 'string') {
89
+ return (a as Record<string, unknown> | null)?.[props.by] === (b as Record<string, unknown> | null)?.[props.by]
90
+ }
91
+
92
+ return a === b
23
93
  }
24
94
 
25
- provide('selectOption', selectOption)
26
- provide('hide', handleHide)
95
+ function toCssSize(value?: string | number): string | undefined {
96
+ if (value == null) {
97
+ return undefined
98
+ }
99
+
100
+ return typeof value === 'number' ? `${value}px` : value
101
+ }
27
102
  </script>
28
103
 
29
104
  <template>
30
- <Combobox v-model="modelValue" :default-value="modelValue" :options="[{ groupLabel: '', children: props.options }]" />
105
+ <SelectRoot
106
+ v-model="modelValue"
107
+ :by="props.by"
108
+ :disabled="props.disabled"
109
+ >
110
+ <SelectTrigger
111
+ :class="[
112
+ 'group',
113
+ 'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-fit gap-[5px] outline-none',
114
+ 'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
115
+ 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' : '',
116
+ props.variant === 'blurry' ? 'bg-neutral-50/70 dark:bg-neutral-800/70 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
117
+ props.variant === 'blurry' ? 'backdrop-blur-md' : '',
118
+ 'border-2 border-solid focus:border-primary-300 dark:focus:border-primary-400/50',
119
+ props.variant === 'default' ? 'border-neutral-200 dark:border-neutral-800' : '',
120
+ props.variant === 'blurry' ? 'border-neutral-100/60 dark:border-neutral-800/30' : '',
121
+ 'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black/10 dark:focus:shadow-black/30',
122
+ 'transition-colors duration-200 ease-in-out',
123
+ props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
124
+ ]"
125
+ >
126
+ <div :class="['min-w-0 flex-1 text-left']">
127
+ <slot
128
+ v-if="$slots.value"
129
+ name="value"
130
+ v-bind="{ option: selectedOption, value: modelValue, placeholder: props.placeholder }"
131
+ >
132
+ <span
133
+ :class="[
134
+ 'block truncate',
135
+ selectedOption
136
+ ? 'text-neutral-700 dark:text-neutral-200'
137
+ : 'text-neutral-400 dark:text-neutral-500',
138
+ ]"
139
+ >
140
+ {{ selectedOption?.label ?? props.placeholder }}
141
+ </span>
142
+ </slot>
143
+ <SelectValue
144
+ v-else
145
+ v-model="modelValue"
146
+ :placeholder="props.placeholder"
147
+ />
148
+ </div>
149
+ <SelectIcon as-child>
150
+ <div
151
+ i-solar:alt-arrow-down-linear
152
+ :class="[
153
+ 'h-4 w-4 shrink-0',
154
+ 'text-neutral-700 dark:text-neutral-200',
155
+ 'transition-transform duration-200 ease-in-out',
156
+ 'group-data-[state=open]:rotate-180',
157
+ ]"
158
+ />
159
+ </SelectIcon>
160
+ </SelectTrigger>
161
+
162
+ <SelectPortal>
163
+ <SelectContent
164
+ position="popper"
165
+ side="bottom"
166
+ align="start"
167
+ :side-offset="4"
168
+ :avoid-collisions="true"
169
+ :class="[
170
+ // NOTICE: DialogContent/DialogOverlay use z-[9999], and DrawerContent uses z-[1000].
171
+ // SelectContent must render above these layers so that dropdowns inside
172
+ // Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
173
+ // Read more at: https://github.com/moeru-ai/airi/issues/1136
174
+ 'z-[10010]',
175
+ 'overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
176
+ 'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
177
+ 'bg-white dark:bg-neutral-900',
178
+ 'border-neutral-200 dark:border-neutral-800 border-solid border-2',
179
+ ]"
180
+ :style="{
181
+ width: toCssSize(props.contentWidth) ?? 'var(--reka-select-trigger-width)',
182
+ minWidth: toCssSize(props.contentMinWidth),
183
+ }"
184
+ >
185
+ <SelectViewport
186
+ :class="[
187
+ 'p-[2px]',
188
+ 'max-h-50dvh',
189
+ 'overflow-y-auto',
190
+ ]"
191
+ >
192
+ <template
193
+ v-for="(group, groupIndex) in normalizedOptions"
194
+ :key="group.groupLabel || `group-${groupIndex}`"
195
+ >
196
+ <SelectGroup :class="['overflow-x-hidden']">
197
+ <SelectSeparator
198
+ v-if="groupIndex !== 0"
199
+ :class="['m-[5px]', 'h-[1px]', 'bg-neutral-200 dark:bg-neutral-800']"
200
+ />
201
+
202
+ <SelectLabel
203
+ v-if="group.groupLabel"
204
+ :class="[
205
+ 'px-[25px] text-xs leading-[25px]',
206
+ 'text-neutral-500 dark:text-neutral-400',
207
+ 'transition-colors duration-200 ease-in-out',
208
+ ]"
209
+ >
210
+ {{ group.groupLabel }}
211
+ </SelectLabel>
212
+
213
+ <SelectOption
214
+ v-for="(option, optionIndex) in group.children || []"
215
+ :key="`${group.groupLabel || groupIndex}-${option.label}-${optionIndex}`"
216
+ :option="option"
217
+ >
218
+ <template
219
+ v-if="$slots.option"
220
+ #default="{ option: slotOption }"
221
+ >
222
+ <slot
223
+ name="option"
224
+ v-bind="{ option: slotOption }"
225
+ />
226
+ </template>
227
+ </SelectOption>
228
+ </SelectGroup>
229
+ </template>
230
+ </SelectViewport>
231
+
232
+ <SelectArrow
233
+ :class="[
234
+ 'fill-white dark:fill-neutral-900',
235
+ 'stroke-neutral-200 dark:stroke-neutral-800',
236
+ ]"
237
+ />
238
+ </SelectContent>
239
+ </SelectPortal>
240
+ </SelectRoot>
31
241
  </template>