@hina-ui/vue 1.1.0 → 1.2.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 (122) hide show
  1. package/dist/index.js +485 -262
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/accordion/AccordionContent.vue.d.ts.map +1 -1
  4. package/dist/types/components/avatar/AvatarGroup.vue.d.ts.map +1 -1
  5. package/dist/types/components/avatar/utils/children.d.ts +3 -0
  6. package/dist/types/components/avatar/utils/children.d.ts.map +1 -0
  7. package/dist/types/components/checkbox-group/CheckboxGroup.vue.d.ts +32 -29
  8. package/dist/types/components/checkbox-group/CheckboxGroup.vue.d.ts.map +1 -1
  9. package/dist/types/components/collapsible/CollapsibleContent.vue.d.ts.map +1 -1
  10. package/dist/types/components/combobox/Combobox.vue.d.ts +38 -38
  11. package/dist/types/components/combobox/Combobox.vue.d.ts.map +1 -1
  12. package/dist/types/components/combobox/ComboboxList.vue.d.ts +22 -15
  13. package/dist/types/components/combobox/ComboboxList.vue.d.ts.map +1 -1
  14. package/dist/types/components/dialog/Dialog.vue.d.ts +22 -16
  15. package/dist/types/components/dialog/Dialog.vue.d.ts.map +1 -1
  16. package/dist/types/components/dialog/dialog.variants.d.ts +32 -2
  17. package/dist/types/components/dialog/dialog.variants.d.ts.map +1 -1
  18. package/dist/types/components/image/ImageGroup.vue.d.ts.map +1 -1
  19. package/dist/types/components/image/composables/useImageGroupState.d.ts +7 -0
  20. package/dist/types/components/image/composables/useImageGroupState.d.ts.map +1 -0
  21. package/dist/types/components/input/Input.vue.d.ts +3 -3
  22. package/dist/types/components/input/Input.vue.d.ts.map +1 -1
  23. package/dist/types/components/input/InputBase.vue.d.ts +3 -3
  24. package/dist/types/components/input/InputBase.vue.d.ts.map +1 -1
  25. package/dist/types/components/input/input.variants.d.ts +52 -6
  26. package/dist/types/components/input/input.variants.d.ts.map +1 -1
  27. package/dist/types/components/input-group/InputGroup.vue.d.ts +3 -3
  28. package/dist/types/components/input-group/InputGroup.vue.d.ts.map +1 -1
  29. package/dist/types/components/input-group/context.d.ts +2 -1
  30. package/dist/types/components/input-group/context.d.ts.map +1 -1
  31. package/dist/types/components/input-group/input-group.variants.d.ts +13 -1
  32. package/dist/types/components/input-group/input-group.variants.d.ts.map +1 -1
  33. package/dist/types/components/listbox/Listbox.vue.d.ts +36 -29
  34. package/dist/types/components/listbox/Listbox.vue.d.ts.map +1 -1
  35. package/dist/types/components/listbox/ListboxOptionContent.vue.d.ts +31 -0
  36. package/dist/types/components/listbox/ListboxOptionContent.vue.d.ts.map +1 -0
  37. package/dist/types/components/listbox/listbox.variants.d.ts +19 -1
  38. package/dist/types/components/listbox/listbox.variants.d.ts.map +1 -1
  39. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts +41 -43
  40. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts.map +1 -1
  41. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts +27 -0
  42. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts.map +1 -0
  43. package/dist/types/components/multi-select/MultiSelect.vue.d.ts +39 -41
  44. package/dist/types/components/multi-select/MultiSelect.vue.d.ts.map +1 -1
  45. package/dist/types/components/number-input/NumberInput.vue.d.ts +3 -3
  46. package/dist/types/components/number-input/NumberInput.vue.d.ts.map +1 -1
  47. package/dist/types/components/number-input/number-input.variants.d.ts +10 -1
  48. package/dist/types/components/number-input/number-input.variants.d.ts.map +1 -1
  49. package/dist/types/components/password-input/PasswordInput.vue.d.ts +3 -3
  50. package/dist/types/components/password-input/PasswordInput.vue.d.ts.map +1 -1
  51. package/dist/types/components/radio-group/RadioGroup.vue.d.ts +32 -29
  52. package/dist/types/components/radio-group/RadioGroup.vue.d.ts.map +1 -1
  53. package/dist/types/components/scroll-area/scroll-restore.d.ts +0 -1
  54. package/dist/types/components/scroll-area/scroll-restore.d.ts.map +1 -1
  55. package/dist/types/components/search-input/SearchInput.vue.d.ts +3 -3
  56. package/dist/types/components/search-input/SearchInput.vue.d.ts.map +1 -1
  57. package/dist/types/components/segmented-control/SegmentedControl.vue.d.ts +30 -30
  58. package/dist/types/components/segmented-control/SegmentedControl.vue.d.ts.map +1 -1
  59. package/dist/types/components/select/Select.vue.d.ts +41 -37
  60. package/dist/types/components/select/Select.vue.d.ts.map +1 -1
  61. package/dist/types/components/select/SelectList.vue.d.ts +22 -15
  62. package/dist/types/components/select/SelectList.vue.d.ts.map +1 -1
  63. package/dist/types/components/select/composables/useClearTransition.d.ts +10 -0
  64. package/dist/types/components/select/composables/useClearTransition.d.ts.map +1 -0
  65. package/dist/types/components/select/select.variants.d.ts +16 -0
  66. package/dist/types/components/select/select.variants.d.ts.map +1 -1
  67. package/dist/types/components/select/types.d.ts +7 -7
  68. package/dist/types/components/select/types.d.ts.map +1 -1
  69. package/dist/types/components/textarea/Textarea.vue.d.ts.map +1 -1
  70. package/dist/types/components/textarea/composables/useTextareaSizing.d.ts +27 -0
  71. package/dist/types/components/textarea/composables/useTextareaSizing.d.ts.map +1 -0
  72. package/dist/types/components/textarea/textarea.variants.d.ts +9 -6
  73. package/dist/types/components/textarea/textarea.variants.d.ts.map +1 -1
  74. package/dist/types/lib/caret.d.ts +0 -4
  75. package/dist/types/lib/caret.d.ts.map +1 -1
  76. package/dist/types/lib/collapse.d.ts +2 -0
  77. package/dist/types/lib/collapse.d.ts.map +1 -1
  78. package/dist/types/lib/field-focus.d.ts +0 -4
  79. package/dist/types/lib/field-focus.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/src/components/accordion/AccordionContent.vue +2 -11
  82. package/src/components/avatar/AvatarGroup.vue +3 -8
  83. package/src/components/avatar/utils/children.ts +7 -0
  84. package/src/components/checkbox-group/CheckboxGroup.vue +3 -3
  85. package/src/components/collapsible/CollapsibleContent.vue +2 -11
  86. package/src/components/combobox/Combobox.vue +3 -3
  87. package/src/components/combobox/ComboboxList.vue +3 -3
  88. package/src/components/dialog/Dialog.vue +70 -39
  89. package/src/components/dialog/dialog.variants.ts +9 -3
  90. package/src/components/image/ImageGroup.vue +2 -30
  91. package/src/components/image/composables/useImageGroupState.ts +35 -0
  92. package/src/components/input/Input.vue +3 -3
  93. package/src/components/input/InputBase.vue +5 -5
  94. package/src/components/input/input.variants.ts +13 -3
  95. package/src/components/input-group/InputGroup.vue +11 -4
  96. package/src/components/input-group/context.ts +2 -1
  97. package/src/components/input-group/input-group.variants.ts +8 -1
  98. package/src/components/listbox/Listbox.vue +26 -29
  99. package/src/components/listbox/ListboxOptionContent.vue +28 -0
  100. package/src/components/listbox/listbox.variants.ts +6 -1
  101. package/src/components/multi-combobox/MultiCombobox.vue +14 -52
  102. package/src/components/multi-combobox/composables/useMultiCombobox.ts +63 -0
  103. package/src/components/multi-select/MultiSelect.vue +3 -4
  104. package/src/components/number-input/NumberInput.vue +8 -7
  105. package/src/components/number-input/number-input.variants.ts +5 -1
  106. package/src/components/password-input/PasswordInput.vue +3 -3
  107. package/src/components/radio-group/RadioGroup.vue +3 -3
  108. package/src/components/scroll-area/scroll-restore.ts +0 -5
  109. package/src/components/search-input/SearchInput.vue +3 -3
  110. package/src/components/segmented-control/SegmentedControl.vue +3 -3
  111. package/src/components/select/Select.vue +68 -25
  112. package/src/components/select/SelectList.vue +3 -3
  113. package/src/components/select/composables/useClearTransition.ts +25 -0
  114. package/src/components/select/select.variants.ts +24 -0
  115. package/src/components/select/types.ts +10 -8
  116. package/src/components/textarea/Textarea.vue +2 -41
  117. package/src/components/textarea/composables/useTextareaSizing.ts +54 -0
  118. package/src/components/textarea/textarea.variants.ts +5 -4
  119. package/src/lib/caret.ts +0 -4
  120. package/src/lib/collapse.ts +21 -1
  121. package/src/lib/field-focus.ts +0 -4
  122. package/src/styles/interaction.css +4 -2
@@ -1,9 +1,8 @@
1
- <script setup lang="ts">
2
- import { computed, reactive, ref, shallowRef, watch } from 'vue'
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
+ import { computed } from 'vue'
3
3
  import { ComboboxAnchor, ComboboxInput, ComboboxRoot, ComboboxTrigger } from 'reka-ui'
4
4
  import { cn } from '../../lib/cn'
5
5
  import { useFieldControl } from '../form-field/context'
6
- import { focusFieldFrom } from '../../lib/field-focus'
7
6
  import { useUiLocale } from '../../locale'
8
7
  import { buttonIconBox } from '../button/button.variants'
9
8
  import IconSlot from '../button/IconSlot.vue'
@@ -19,7 +18,8 @@
19
18
  inputIndicator,
20
19
  type InputVariants,
21
20
  } from '../input/input.variants'
22
- import { flattenOptions, type SelectItems, type SelectOption } from '../select/types'
21
+ import type { SelectItems, SelectOption } from '../select/types'
22
+ import { useMultiCombobox } from './composables/useMultiCombobox'
23
23
  import {
24
24
  tagsInputChip,
25
25
  tagsInputControl,
@@ -32,7 +32,7 @@
32
32
 
33
33
  const props = withDefaults(
34
34
  defineProps<{
35
- options: SelectItems
35
+ options: SelectItems<T>
36
36
  placeholder?: string
37
37
  ignoreFilter?: boolean
38
38
  loading?: boolean
@@ -52,11 +52,9 @@
52
52
  const search = defineModel<string>('search', { default: '' })
53
53
  const open = defineModel<boolean>('open', { default: false })
54
54
 
55
- defineSlots<{ option(props: { option: SelectOption }): unknown }>()
55
+ defineSlots<{ option?(props: { option: T }): unknown }>()
56
56
 
57
57
  const t = useUiLocale()
58
- const keyboard = ref(false)
59
- const input = shallowRef<{ $el: HTMLInputElement } | null>(null)
60
58
 
61
59
  const {
62
60
  id: fieldId,
@@ -68,52 +66,16 @@
68
66
  disabled: () => props.disabled,
69
67
  })
70
68
 
71
- const labels = reactive(new Map<string | number, string>())
72
- watch(
73
- () => props.options,
74
- options => {
75
- for (const option of flattenOptions(options)) labels.set(option.value, option.label)
76
- },
77
- { immediate: true, deep: true },
78
- )
79
-
80
- const selected = computed(() =>
81
- model.value.map(value => ({ value, label: labels.get(value) ?? String(value) })),
82
- )
69
+ const { selected, keyboard, input, remove, clear, onHostClick, onInputKeydown } =
70
+ useMultiCombobox({
71
+ options: () => props.options,
72
+ model,
73
+ open,
74
+ disabled: () => disabled.value,
75
+ onClear: () => emit('clear'),
76
+ })
83
77
  const chipSize = computed(() => (props.size === 'sm' ? 'sm' : 'md'))
84
78
  const clearing = computed(() => props.clearable && selected.value.length > 0 && !disabled.value)
85
-
86
- watch(open, value => {
87
- if (!value) keyboard.value = false
88
- })
89
-
90
- function remove(value: string | number) {
91
- if (disabled.value) return
92
- model.value = model.value.filter(item => item !== value)
93
- }
94
-
95
- function clear() {
96
- if (!model.value.length) return
97
- model.value = []
98
- emit('clear')
99
- input.value?.$el.focus()
100
- }
101
-
102
- function onHostClick(event: MouseEvent) {
103
- const focused = focusFieldFrom(event.currentTarget as HTMLElement, event.target as HTMLElement)
104
- if (focused && !open.value) open.value = true
105
- }
106
-
107
- function onInputKeydown(event: KeyboardEvent) {
108
- keyboard.value = true
109
- if (
110
- event.key !== 'Backspace' ||
111
- (event.target as HTMLInputElement).value !== '' ||
112
- !model.value.length
113
- )
114
- return
115
- model.value = model.value.slice(0, -1)
116
- }
117
79
  </script>
118
80
 
119
81
  <template>
@@ -0,0 +1,63 @@
1
+ import { computed, reactive, ref, shallowRef, watch, type Ref } from 'vue'
2
+ import { focusFieldFrom } from '../../../lib/field-focus'
3
+ import { flattenOptions, type SelectItems, type SelectOption } from '../../select/types'
4
+
5
+ interface MultiComboboxOptions<T extends SelectOption> {
6
+ options: () => SelectItems<T>
7
+ model: Ref<Array<string | number>>
8
+ open: Ref<boolean>
9
+ disabled: () => boolean
10
+ onClear: () => void
11
+ }
12
+
13
+ export function useMultiCombobox<T extends SelectOption>(options: MultiComboboxOptions<T>) {
14
+ const { model, open } = options
15
+ const keyboard = ref(false)
16
+ const input = shallowRef<{ $el: HTMLInputElement } | null>(null)
17
+ const labels = reactive(new Map<string | number, string>())
18
+ watch(
19
+ options.options,
20
+ options => {
21
+ for (const option of flattenOptions(options)) labels.set(option.value, option.label)
22
+ },
23
+ { immediate: true, deep: true },
24
+ )
25
+
26
+ const selected = computed(() =>
27
+ model.value.map(value => ({ value, label: labels.get(value) ?? String(value) })),
28
+ )
29
+
30
+ watch(open, value => {
31
+ if (!value) keyboard.value = false
32
+ })
33
+
34
+ function remove(value: string | number) {
35
+ if (options.disabled()) return
36
+ model.value = model.value.filter(item => item !== value)
37
+ }
38
+
39
+ function clear() {
40
+ if (!model.value.length) return
41
+ model.value = []
42
+ options.onClear()
43
+ input.value?.$el.focus()
44
+ }
45
+
46
+ function onHostClick(event: MouseEvent) {
47
+ const focused = focusFieldFrom(event.currentTarget as HTMLElement, event.target as HTMLElement)
48
+ if (focused && !open.value) open.value = true
49
+ }
50
+
51
+ function onInputKeydown(event: KeyboardEvent) {
52
+ keyboard.value = true
53
+ if (
54
+ event.key !== 'Backspace' ||
55
+ (event.target as HTMLInputElement).value !== '' ||
56
+ !model.value.length
57
+ )
58
+ return
59
+ model.value = model.value.slice(0, -1)
60
+ }
61
+
62
+ return { selected, keyboard, input, remove, clear, onHostClick, onInputKeydown }
63
+ }
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
2
  import { computed, ref } from 'vue'
3
3
  import { SelectRoot, SelectTrigger } from 'reka-ui'
4
4
  import { cn } from '../../lib/cn'
@@ -25,7 +25,7 @@
25
25
 
26
26
  const props = withDefaults(
27
27
  defineProps<{
28
- options: SelectItems
28
+ options: SelectItems<T>
29
29
  placeholder?: string
30
30
  name?: string
31
31
  required?: boolean
@@ -45,7 +45,7 @@
45
45
  const model = defineModel<Array<string | number>>({ default: () => [] })
46
46
  const open = defineModel<boolean>('open', { default: false })
47
47
 
48
- defineSlots<{ option(props: { option: SelectOption }): unknown }>()
48
+ defineSlots<{ option?(props: { option: T }): unknown }>()
49
49
 
50
50
  const t = useUiLocale()
51
51
  const group = injectInputGroup()
@@ -157,7 +157,6 @@
157
157
  <DisclosureIcon />
158
158
  </span>
159
159
  </SelectTrigger>
160
- <!-- Reka 2.10 BubbleSelect assigns arrays to select.value, which loses multiple values. -->
161
160
  <VisuallyHidden v-if="props.name" as-child>
162
161
  <select
163
162
  v-model="model"
@@ -16,8 +16,8 @@
16
16
  import {
17
17
  inputControl,
18
18
  inputEmbedded,
19
- inputHost,
20
- type InputVariants,
19
+ textInputHost,
20
+ type TextInputVariants,
21
21
  } from '../input/input.variants'
22
22
  import { numberInputStep, numberInputStepper } from './number-input.variants'
23
23
 
@@ -34,8 +34,8 @@
34
34
  formatOptions?: Intl.NumberFormatOptions
35
35
  locale?: string
36
36
  controls?: boolean
37
- variant?: InputVariants['variant']
38
- size?: InputVariants['size']
37
+ variant?: TextInputVariants['variant']
38
+ size?: TextInputVariants['size']
39
39
  disabled?: boolean
40
40
  readonly?: boolean
41
41
  invalid?: boolean
@@ -47,6 +47,7 @@
47
47
 
48
48
  const t = useUiLocale()
49
49
  const group = injectInputGroup()
50
+ const divided = computed(() => (group ? group.variant?.value : props.variant) !== 'bare')
50
51
  const size = computed(() => (group ? group.size.value : props.size))
51
52
  const {
52
53
  id: fieldId,
@@ -83,7 +84,7 @@
83
84
  :data-disabled="disabled ? '' : undefined"
84
85
  :class="
85
86
  cn(
86
- group ? inputEmbedded() : inputHost({ variant: props.variant, size: props.size }),
87
+ group ? inputEmbedded() : textInputHost({ variant: props.variant, size: props.size }),
87
88
  props.class,
88
89
  )
89
90
  "
@@ -95,13 +96,13 @@
95
96
  :aria-describedby="describedBy"
96
97
  :class="cn(inputControl(), 'tabular-nums')"
97
98
  />
98
- <div v-if="props.controls" :class="numberInputStepper({ size })">
99
+ <div v-if="props.controls" :class="numberInputStepper({ size, divided })">
99
100
  <NumberFieldIncrement :aria-label="t.numberInput.increase" :class="numberInputStep()">
100
101
  <ChevronUp />
101
102
  </NumberFieldIncrement>
102
103
  <NumberFieldDecrement
103
104
  :aria-label="t.numberInput.decrease"
104
- :class="numberInputStep({ divided: true })"
105
+ :class="numberInputStep({ divided })"
105
106
  >
106
107
  <ChevronDown />
107
108
  </NumberFieldDecrement>
@@ -1,8 +1,11 @@
1
1
  import { tv } from '../../lib/tv'
2
2
 
3
3
  export const numberInputStepper = tv({
4
- base: 'border-line flex shrink-0 flex-col border-s',
4
+ base: 'flex shrink-0 flex-col',
5
5
  variants: {
6
+ divided: {
7
+ true: 'border-line border-s',
8
+ },
6
9
  size: {
7
10
  sm: '[&_svg]:size-3',
8
11
  md: '[&_svg]:size-3',
@@ -11,6 +14,7 @@ export const numberInputStepper = tv({
11
14
  },
12
15
  defaultVariants: {
13
16
  size: 'md',
17
+ divided: true,
14
18
  },
15
19
  })
16
20
 
@@ -4,13 +4,13 @@
4
4
  import { useUiLocale } from '../../locale'
5
5
  import InputBase from '../input/InputBase.vue'
6
6
  import InputAction from '../input/InputAction.vue'
7
- import type { InputVariants } from '../input/input.variants'
7
+ import type { TextInputVariants } from '../input/input.variants'
8
8
 
9
9
  defineOptions({ name: 'HnPasswordInput', inheritAttrs: false })
10
10
 
11
11
  const props = defineProps<{
12
- variant?: InputVariants['variant']
13
- size?: InputVariants['size']
12
+ variant?: TextInputVariants['variant']
13
+ size?: TextInputVariants['size']
14
14
  disabled?: boolean
15
15
  invalid?: boolean
16
16
  class?: string
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
2
  import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
3
3
  import { cn } from '../../lib/cn'
4
4
  import { useFieldControl } from '../form-field/context'
@@ -22,7 +22,7 @@
22
22
  defineOptions({ name: 'HnRadioGroup' })
23
23
 
24
24
  const props = defineProps<{
25
- options: SelectOption[]
25
+ options: T[]
26
26
  orientation?: CheckboxGroupVariants['orientation']
27
27
  size?: CheckboxVariants['size']
28
28
  controlPlacement?: CheckboxVariants['controlPlacement']
@@ -39,7 +39,7 @@
39
39
  disabled: () => props.disabled,
40
40
  })
41
41
 
42
- defineSlots<{ option(props: { option: SelectOption }): unknown }>()
42
+ defineSlots<{ option?(props: { option: T }): unknown }>()
43
43
  </script>
44
44
 
45
45
  <template>
@@ -37,7 +37,6 @@ export type ScrollRecord = Record<string, number>
37
37
 
38
38
  export interface ScrollRestorerPorts {
39
39
  getViewport: () => HTMLElement | null
40
- /** Where the first-paint script left the scroller, read once and cleared. */
41
40
  takeFirstPaintTop: () => number | undefined
42
41
  readState: () => unknown
43
42
  writeState: (state: Record<string, unknown>) => void
@@ -110,10 +109,6 @@ export function createScrollRestorer(key: string, ports: ScrollRestorerPorts): S
110
109
  const firstPaintTop = ports.takeFirstPaintTop()
111
110
 
112
111
  if (initial) {
113
- // The first-paint script already put the scroller where this entry
114
- // wants it, so anything else means the reader moved it while the page
115
- // was still hydrating — the wheel listener that would have cancelled a
116
- // restore is only attached from here on.
117
112
  if (saved === undefined) return
118
113
  const el = ports.getViewport()
119
114
  if (firstPaintTop !== undefined && el && el.scrollTop !== firstPaintTop) return
@@ -2,14 +2,14 @@
2
2
  import { Search } from '@lucide/vue'
3
3
  import { cn } from '../../lib/cn'
4
4
  import InputBase from '../input/InputBase.vue'
5
- import type { InputVariants } from '../input/input.variants'
5
+ import type { TextInputVariants } from '../input/input.variants'
6
6
 
7
7
  defineOptions({ name: 'HnSearchInput', inheritAttrs: false })
8
8
 
9
9
  const props = withDefaults(
10
10
  defineProps<{
11
- variant?: InputVariants['variant']
12
- size?: InputVariants['size']
11
+ variant?: TextInputVariants['variant']
12
+ size?: TextInputVariants['size']
13
13
  clearable?: boolean
14
14
  loading?: boolean
15
15
  disabled?: boolean
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
2
  import { ToggleGroupItem, ToggleGroupRoot, type AcceptableValue } from 'reka-ui'
3
3
  import { computed, useId, useSlots } from 'vue'
4
4
  import { cn } from '../../lib/cn'
@@ -18,7 +18,7 @@
18
18
 
19
19
  const props = withDefaults(
20
20
  defineProps<{
21
- options: SelectOption[]
21
+ options: T[]
22
22
  size?: SegmentedItemVariants['size']
23
23
  orientation?: SegmentedControlVariants['orientation']
24
24
  block?: boolean
@@ -30,7 +30,7 @@
30
30
 
31
31
  const model = defineModel<string | number>()
32
32
 
33
- defineSlots<{ option(props: { option: SelectOption }): unknown }>()
33
+ defineSlots<{ option?(props: { option: T }): unknown }>()
34
34
 
35
35
  const slots = useSlots()
36
36
 
@@ -1,8 +1,10 @@
1
- <script setup lang="ts">
2
- import { computed, ref } from 'vue'
3
- import { SelectRoot, SelectTrigger } from 'reka-ui'
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
+ import { computed, ref, shallowRef } from 'vue'
3
+ import { SelectRoot, SelectTrigger, useDirection } from 'reka-ui'
4
4
  import { cn } from '../../lib/cn'
5
5
  import { useUiLocale } from '../../locale'
6
+ import { X } from '@lucide/vue'
7
+ import InputAction from '../input/InputAction.vue'
6
8
  import DisclosureIcon from '../disclosure-icon/DisclosureIcon.vue'
7
9
  import { useFieldControl } from '../form-field/context'
8
10
  import { injectInputGroup } from '../input-group/context'
@@ -13,14 +15,16 @@
13
15
  type InputVariants,
14
16
  } from '../input/input.variants'
15
17
  import SelectList from './SelectList.vue'
16
- import { selectTrigger } from './select.variants'
18
+ import { selectButton, selectClearSlot, selectHost, selectValue } from './select.variants'
19
+ import { useClearTransition } from './composables/useClearTransition'
17
20
  import { flattenOptions, type SelectItems, type SelectOption } from './types'
18
21
 
19
22
  defineOptions({ name: 'HnSelect', inheritAttrs: false })
20
23
 
21
24
  const props = defineProps<{
22
- options: SelectItems
25
+ options: SelectItems<T>
23
26
  placeholder?: string
27
+ clearable?: boolean
24
28
  name?: string
25
29
  required?: boolean
26
30
  autocomplete?: string
@@ -31,17 +35,22 @@
31
35
  class?: string
32
36
  }>()
33
37
 
38
+ const emit = defineEmits<{ clear: [] }>()
39
+
34
40
  const model = defineModel<string | number | null>()
35
41
  const open = defineModel<boolean>('open', { default: false })
36
42
 
37
43
  defineSlots<{
38
- value(props: { option: SelectOption }): unknown
39
- option(props: { option: SelectOption }): unknown
44
+ value?(props: { option: T }): unknown
45
+ option?(props: { option: T }): unknown
40
46
  }>()
41
47
 
42
48
  const t = useUiLocale()
43
49
  const group = injectInputGroup()
50
+ const direction = useDirection()
44
51
  const keyboard = ref(false)
52
+ const host = shallowRef<HTMLElement | null>(null)
53
+ const trigger = shallowRef<{ $el: HTMLElement } | null>(null)
45
54
 
46
55
  const {
47
56
  id: fieldId,
@@ -55,6 +64,18 @@
55
64
  const selected = computed(() =>
56
65
  flattenOptions(props.options).find(option => option.value === model.value),
57
66
  )
67
+ const clearing = computed(
68
+ () => !!props.clearable && model.value != null && model.value !== '' && !disabled.value,
69
+ )
70
+
71
+ const { reserved: clearSpace, hooks: clearTransition } = useClearTransition(() => clearing.value)
72
+
73
+ function clear() {
74
+ if (disabled.value || model.value == null || model.value === '') return
75
+ model.value = null
76
+ emit('clear')
77
+ trigger.value?.$el.focus()
78
+ }
58
79
  </script>
59
80
 
60
81
  <template>
@@ -66,34 +87,56 @@
66
87
  :required="props.required"
67
88
  :autocomplete="props.autocomplete"
68
89
  >
69
- <SelectTrigger
70
- v-bind="$attrs"
71
- :id="fieldId"
72
- :aria-describedby="describedBy"
90
+ <div
91
+ ref="host"
92
+ :dir="direction"
73
93
  data-hn-select
74
- @keydown="keyboard = true"
75
- @pointerdown="keyboard = false"
76
94
  :data-invalid="invalid ? '' : undefined"
77
95
  :data-disabled="disabled ? '' : undefined"
78
- :aria-invalid="invalid || undefined"
79
96
  :class="
80
97
  cn(
81
98
  group ? inputEmbedded() : inputHost({ variant: props.variant, size: props.size }),
82
- selectTrigger(),
99
+ selectHost(),
83
100
  props.class,
84
101
  )
85
102
  "
86
103
  >
87
- <span class="min-w-0 flex-1 truncate">
88
- <template v-if="selected">
89
- <slot name="value" :option="selected">{{ selected.label }}</slot>
90
- </template>
91
- <template v-else>{{ props.placeholder ?? t.select.placeholder }}</template>
92
- </span>
93
- <span :class="inputAdornment()">
94
- <DisclosureIcon />
95
- </span>
96
- </SelectTrigger>
104
+ <SelectTrigger
105
+ ref="trigger"
106
+ v-bind="$attrs"
107
+ :reference="host ?? undefined"
108
+ :id="fieldId"
109
+ :aria-describedby="describedBy"
110
+ :aria-invalid="invalid || undefined"
111
+ data-hn-select-trigger
112
+ @keydown="keyboard = true"
113
+ @pointerdown="keyboard = false"
114
+ :class="selectButton()"
115
+ >
116
+ <span :class="selectValue({ clearing: clearSpace })">
117
+ <template v-if="selected">
118
+ <slot name="value" :option="selected">{{ selected.label }}</slot>
119
+ </template>
120
+ <template v-else>{{ props.placeholder ?? t.select.placeholder }}</template>
121
+ </span>
122
+ <span :class="inputAdornment()">
123
+ <DisclosureIcon />
124
+ </span>
125
+ </SelectTrigger>
126
+ <Transition
127
+ enter-active-class="hn-transition-base"
128
+ enter-from-class="scale-90 opacity-0"
129
+ leave-active-class="hn-transition"
130
+ leave-to-class="scale-90 opacity-0"
131
+ v-on="clearTransition"
132
+ >
133
+ <span v-if="clearing" :class="selectClearSlot()">
134
+ <InputAction data-hn-select-clear :label="t.common.clear" @click="clear">
135
+ <X />
136
+ </InputAction>
137
+ </span>
138
+ </Transition>
139
+ </div>
97
140
  <SelectList :options="props.options" :keyboard="keyboard">
98
141
  <template #option="slotProps">
99
142
  <slot name="option" v-bind="slotProps" />
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
2
  import { Check } from '@lucide/vue'
3
3
  import {
4
4
  SelectContent,
@@ -25,11 +25,11 @@
25
25
 
26
26
  defineOptions({ name: 'HnSelectList' })
27
27
 
28
- const props = defineProps<{ options: SelectItems; keyboard?: boolean }>()
28
+ const props = defineProps<{ options: SelectItems<T>; keyboard?: boolean }>()
29
29
 
30
30
  const fresh = ref(false)
31
31
 
32
- defineSlots<{ option(props: { option: SelectOption }): unknown }>()
32
+ defineSlots<{ option?(props: { option: T }): unknown }>()
33
33
 
34
34
  const t = useUiLocale()
35
35
  </script>
@@ -0,0 +1,25 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ export function useClearTransition(visible: () => boolean) {
4
+ const leaving = ref(false)
5
+ const reserved = computed(() => visible() || leaving.value)
6
+
7
+ function beforeEnter(element: Element) {
8
+ leaving.value = false
9
+ element.removeAttribute('inert')
10
+ }
11
+
12
+ function beforeLeave(element: Element) {
13
+ leaving.value = true
14
+ element.setAttribute('inert', '')
15
+ }
16
+
17
+ function finishLeave() {
18
+ leaving.value = false
19
+ }
20
+
21
+ return {
22
+ reserved,
23
+ hooks: { beforeEnter, beforeLeave, afterLeave: finishLeave, leaveCancelled: finishLeave },
24
+ }
25
+ }
@@ -1,4 +1,5 @@
1
1
  import { tv } from '../../lib/tv'
2
+ import { inputActionSlot } from '../input/input.variants'
2
3
 
3
4
  export const selectTrigger = tv({
4
5
  base: [
@@ -9,6 +10,14 @@ export const selectTrigger = tv({
9
10
  ],
10
11
  })
11
12
 
13
+ export const selectButton = tv({
14
+ base: [
15
+ 'group/hn-disclosure flex h-full min-w-0 flex-1 cursor-pointer items-center bg-transparent ps-[var(--hn-input-px)] pe-0 text-start text-inherit outline-none [font:inherit]',
16
+ 'data-placeholder:text-faint data-placeholder:font-normal disabled:cursor-not-allowed',
17
+ '[&_svg]:size-[var(--hn-input-icon)] [&_svg]:shrink-0',
18
+ ],
19
+ })
20
+
12
21
  export const selectContent = tv({
13
22
  base: [
14
23
  'hn-anim-pop z-(--hn-z-overlay) flex w-[var(--reka-select-trigger-width)] min-w-40 flex-col overflow-hidden shadow-md outline-none',
@@ -40,3 +49,18 @@ export const selectEmpty = tv({
40
49
  export const selectListBody = tv({
41
50
  base: 'flex flex-col p-1',
42
51
  })
52
+
53
+ export const selectHost = tv({
54
+ base: 'relative cursor-pointer',
55
+ })
56
+
57
+ export const selectValue = tv({
58
+ base: 'min-w-0 flex-1 truncate',
59
+ variants: {
60
+ clearing: { true: 'pe-[var(--hn-input-h)]' },
61
+ },
62
+ })
63
+
64
+ export const selectClearSlot = tv({
65
+ base: [inputActionSlot(), 'absolute inset-y-0 end-[var(--hn-input-h)]'],
66
+ })
@@ -1,21 +1,23 @@
1
- export interface SelectOption {
1
+ export type SelectOption<T extends object = object> = {
2
2
  value: string | number
3
3
  label: string
4
4
  description?: string
5
5
  disabled?: boolean
6
- }
6
+ } & T
7
7
 
8
- export interface SelectOptionGroup {
8
+ export interface SelectOptionGroup<T extends SelectOption = SelectOption> {
9
9
  label: string
10
- options: SelectOption[]
10
+ options: T[]
11
11
  }
12
12
 
13
- export type SelectItems = Array<SelectOption | SelectOptionGroup>
13
+ export type SelectItems<T extends SelectOption = SelectOption> = Array<T | SelectOptionGroup<T>>
14
14
 
15
- export function isOptionGroup(item: SelectOption | SelectOptionGroup): item is SelectOptionGroup {
16
- return 'options' in item
15
+ export function isOptionGroup<T extends SelectOption>(
16
+ item: T | SelectOptionGroup<T>,
17
+ ): item is SelectOptionGroup<T> {
18
+ return !('value' in item) && 'options' in item
17
19
  }
18
20
 
19
- export function flattenOptions(items: SelectItems): SelectOption[] {
21
+ export function flattenOptions<T extends SelectOption>(items: SelectItems<T>): T[] {
20
22
  return items.flatMap(item => (isOptionGroup(item) ? item.options : [item]))
21
23
  }