@revojs/ui 0.2.4 → 0.2.6

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.
@@ -10,6 +10,7 @@ export interface SelectProps {
10
10
  options: Array<SelectOption>;
11
11
  label?: string;
12
12
  placeholder?: string;
13
+ emptyLabel?: string;
13
14
  name?: string;
14
15
  required?: boolean;
15
16
  disabled?: boolean;
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <component
3
3
  :is="is"
4
- class="inline-flex cursor-pointer items-center justify-center gap-2 rounded-ui border border-transparent font-[inherit] font-medium leading-none no-underline outline-ring transition-[background-color,border-color,color,opacity] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:opacity-50"
4
+ class="inline-flex cursor-pointer items-center justify-center gap-2 rounded-ui border border-transparent font-[inherit] font-medium leading-normal no-underline outline-ring transition-[background-color,border-color,color,opacity] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:opacity-50"
5
5
  :class="[fonts[font], layouts[layout], sizes[size], texts[text], variants[variant]]"
6
6
  :type="attributes.type ?? 'button'"
7
7
  :disabled="disabled"
@@ -6,62 +6,91 @@
6
6
 
7
7
  <input v-if="name" type="hidden" :name="name" :value="modelValue" :required="required" />
8
8
 
9
- <Popover
10
- v-model="open"
11
- class="w-full"
12
- content-class="min-w-56 p-1"
13
- trigger-class="w-full min-w-48"
14
- trigger-layout="left"
15
- match-trigger-width
16
- :disabled="disabled"
9
+ <div ref="container">
10
+ <div
11
+ ref="trigger"
12
+ class="flex min-h-10 w-full cursor-text items-center gap-2 rounded-ui border border-input px-3 text-sm ring-ring focus-within:ring-2"
13
+ :class="disabled ? 'cursor-not-allowed opacity-50' : ''"
14
+ @click="search?.focus()"
15
+ >
16
+ <input
17
+ ref="search"
18
+ v-model="query"
19
+ role="combobox"
20
+ class="w-full min-w-0 bg-transparent py-2 outline-none placeholder:opacity-50"
21
+ :placeholder="placeholder ?? 'Select'"
22
+ :aria-expanded="open"
23
+ :aria-controls="listboxId"
24
+ aria-autocomplete="list"
25
+ :disabled="disabled"
26
+ @focus="open = true"
27
+ @input="open = true"
28
+ @keydown.enter.prevent="open ? filtered.length > 0 && select(filtered[0]) : (open = true)"
29
+ @keydown.escape="open = false"
30
+ @keydown.arrow-down.prevent="open = true"
31
+ />
32
+
33
+ <button
34
+ type="button"
35
+ tabindex="-1"
36
+ class="shrink-0 opacity-60 transition-transform"
37
+ :class="open ? 'rotate-180' : ''"
38
+ :disabled="disabled"
39
+ @click.stop="open = !open"
40
+ >
41
+ <Icon name="arrow-down-01" />
42
+ </button>
43
+ </div>
44
+ </div>
45
+
46
+ <div
47
+ ref="dropdown"
48
+ popover="manual"
49
+ :id="listboxId"
50
+ role="listbox"
51
+ :aria-label="label"
52
+ class="m-0 max-h-[min(16rem,50vh)] overflow-y-auto rounded-ui border border-border bg-popover p-1 text-popover-foreground shadow-xl"
53
+ :style="panel"
17
54
  >
18
- <template #trigger>
19
- <span class="min-w-0 flex-1 truncate text-left" :class="valueVariants[valueState]">
20
- <slot name="value" :option="selected">
21
- {{ selected?.label ?? placeholder ?? "Select" }}
55
+ <Button
56
+ v-for="option in filtered"
57
+ :key="option.value"
58
+ class="w-full"
59
+ variant="ghost"
60
+ layout="left"
61
+ :disabled="option.disabled"
62
+ role="option"
63
+ :aria-selected="option.value === modelValue"
64
+ @click="select(option)"
65
+ >
66
+ <span class="flex w-full items-center justify-between gap-4 text-left">
67
+ <slot name="option" :option="option" :selected="option.value === modelValue">
68
+ {{ option.label }}
22
69
  </slot>
70
+
71
+ <Icon v-if="option.value === modelValue" name="tick-02" />
23
72
  </span>
73
+ </Button>
24
74
 
25
- <Icon name="arrow-down-01" class="opacity-60" />
26
- </template>
27
-
28
- <div role="listbox" :aria-label="label">
29
- <Button
30
- v-for="option in options"
31
- :key="option.value"
32
- class="w-full"
33
- variant="ghost"
34
- layout="left"
35
- :disabled="option.disabled"
36
- role="option"
37
- :aria-selected="option.value === modelValue"
38
- @click="select(option)"
39
- >
40
- <span class="flex w-full items-center justify-between gap-4 text-left">
41
- <slot name="option" :option="option" :selected="option.value === modelValue">
42
- {{ option.label }}
43
- </slot>
44
-
45
- <Icon v-if="option.value === modelValue" name="tick-02" />
46
- </span>
47
- </Button>
48
- </div>
49
- </Popover>
75
+ <p v-if="filtered.length === 0" class="px-2 py-6 text-center text-sm opacity-60">
76
+ {{ emptyLabel }}
77
+ </p>
78
+ </div>
50
79
  </div>
51
80
  </template>
52
81
 
53
82
  <script setup lang="ts">
54
- import { computed, ref } from "vue";
55
- import type { SelectOption, SelectValue, SelectValueState } from "../../components/select.ts";
83
+ import { computed, nextTick, onUnmounted, ref, useId, useTemplateRef, watch } from "vue";
84
+ import type { SelectOption, SelectValue } from "../../components/select.ts";
56
85
  import Button from "./button.vue";
57
86
  import Icon from "./icon.vue";
58
- import Popover from "./popover.vue";
59
87
 
60
88
  const {
61
89
  modelValue = undefined,
62
90
  options,
63
91
  label = undefined,
64
92
  placeholder = undefined,
93
+ emptyLabel = "No results found",
65
94
  name = undefined,
66
95
  required = false,
67
96
  disabled = false,
@@ -70,6 +99,7 @@ const {
70
99
  options: Array<SelectOption>;
71
100
  label?: string;
72
101
  placeholder?: string;
102
+ emptyLabel?: string;
73
103
  name?: string;
74
104
  required?: boolean;
75
105
  disabled?: boolean;
@@ -81,14 +111,35 @@ const emit = defineEmits<{
81
111
 
82
112
  const open = ref(false);
83
113
 
114
+ const container = useTemplateRef("container");
115
+
116
+ const trigger = useTemplateRef("trigger");
117
+
118
+ const search = useTemplateRef("search");
119
+
120
+ const dropdown = useTemplateRef("dropdown");
121
+
122
+ const listboxId = `select-${useId()}`;
123
+
84
124
  const selected = computed(() => options.find((option) => option.value === modelValue));
85
125
 
86
- const valueState = computed<SelectValueState>(() => (selected.value ? "selected" : "placeholder"));
126
+ const query = ref(selected.value?.label ?? "");
87
127
 
88
- const valueVariants: Record<SelectValueState, string> = {
89
- placeholder: "opacity-60",
90
- selected: "",
91
- };
128
+ const panel = ref<Record<string, string>>({});
129
+
130
+ const filtered = computed(() => {
131
+ const term = query.value.trim().toLowerCase();
132
+
133
+ return term === "" ? options : options.filter((option) => option.label.toLowerCase().includes(term));
134
+ });
135
+
136
+ function handlePointerDown(event: PointerEvent) {
137
+ const target = event.target as Node;
138
+
139
+ if (!container.value?.contains(target) && !dropdown.value?.contains(target)) {
140
+ open.value = false;
141
+ }
142
+ }
92
143
 
93
144
  function select(option: SelectOption) {
94
145
  if (!option.disabled) {
@@ -96,4 +147,79 @@ function select(option: SelectOption) {
96
147
  open.value = false;
97
148
  }
98
149
  }
150
+
151
+ function updatePanel() {
152
+ const anchor = trigger.value;
153
+ const list = dropdown.value;
154
+
155
+ if (!anchor || !list) {
156
+ return;
157
+ }
158
+
159
+ const bounds = anchor.getBoundingClientRect();
160
+ const height = list.offsetHeight;
161
+ const width = bounds.width;
162
+ const left = Math.min(Math.max(8, bounds.left), window.innerWidth - width - 8);
163
+ const top =
164
+ bounds.bottom + height + 8 <= window.innerHeight ? bounds.bottom + 8 : Math.max(8, bounds.top - height - 8);
165
+
166
+ panel.value = {
167
+ bottom: "auto",
168
+ left: `${left}px`,
169
+ position: "fixed",
170
+ right: "auto",
171
+ top: `${top}px`,
172
+ width: `${width}px`,
173
+ };
174
+ }
175
+
176
+ watch(selected, (value) => {
177
+ if (!open.value) {
178
+ query.value = value?.label ?? "";
179
+ }
180
+ });
181
+
182
+ watch(open, async (value) => {
183
+ const list = dropdown.value;
184
+
185
+ if (!list) {
186
+ return;
187
+ }
188
+
189
+ if (value && !list.matches(":popover-open")) {
190
+ query.value = "";
191
+
192
+ document.addEventListener("pointerdown", handlePointerDown);
193
+
194
+ window.addEventListener("resize", updatePanel);
195
+
196
+ window.addEventListener("scroll", updatePanel, true);
197
+
198
+ list.showPopover();
199
+
200
+ await nextTick();
201
+
202
+ updatePanel();
203
+
204
+ search.value?.focus();
205
+ } else if (!value && list.matches(":popover-open")) {
206
+ document.removeEventListener("pointerdown", handlePointerDown);
207
+
208
+ window.removeEventListener("resize", updatePanel);
209
+
210
+ window.removeEventListener("scroll", updatePanel, true);
211
+
212
+ list.hidePopover();
213
+
214
+ query.value = selected.value?.label ?? "";
215
+ }
216
+ });
217
+
218
+ onUnmounted(() => {
219
+ document.removeEventListener("pointerdown", handlePointerDown);
220
+
221
+ window.removeEventListener("resize", updatePanel);
222
+
223
+ window.removeEventListener("scroll", updatePanel, true);
224
+ });
99
225
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revojs/ui",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,6 +45,6 @@
45
45
  "@vitejs/plugin-vue": "^6.0.9"
46
46
  },
47
47
  "optionalDependencies": {
48
- "vue": "^3.5.42"
48
+ "vue": "^3.5.43"
49
49
  }
50
50
  }