sprintify-ui 0.0.97 → 0.0.100

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.
@@ -1,8 +1,11 @@
1
1
  <template>
2
- <div>
2
+ <div ref="autocomplete">
3
3
  <div
4
- class="min-h-[42px] rounded border bg-white p-1"
5
- :class="[hasErrorInternal ? 'border-red-600' : 'border-slate-300']"
4
+ class="rounded border bg-white p-1"
5
+ :class="[
6
+ hasErrorInternal ? 'border-red-600' : 'border-slate-300',
7
+ wrapperClass,
8
+ ]"
6
9
  >
7
10
  <div class="-m-0.5 flex flex-wrap">
8
11
  <div
@@ -17,18 +20,14 @@
17
20
  selectionClass(selection),
18
21
  ]"
19
22
  >
20
- <div
21
- class="py-[5px] pl-3 text-sm"
22
- :class="[disabled ? 'pr-3' : 'pr-1']"
23
- >
23
+ <div :class="[selectionLabelClass]">
24
24
  {{ selection.label }}
25
25
  </div>
26
26
  <button
27
27
  v-if="!disabled"
28
28
  type="button"
29
29
  class="flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent pl-1 pr-3 text-xs outline-none"
30
- @click="dontLooseFocus($event, () => removeOption(selection))"
31
- @mousedown="dontLooseFocus"
30
+ @click="removeOption(selection)"
32
31
  >
33
32
 
34
33
  </button>
@@ -36,14 +35,15 @@
36
35
  </div>
37
36
  <div class="grow p-0.5">
38
37
  <input
39
- ref="input"
40
- :disabled="disabled"
38
+ ref="inputElement"
41
39
  :value="keywords"
42
40
  type="text"
43
- :placeholder="$t('sui.select_an_item')"
44
- class="h-[32px] w-full min-w-[50px] border-none p-0 pl-1 shadow-none outline-none focus:border-none focus:shadow-none focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
45
- @focus="onTextFocus"
46
- @blur="onTextBlur"
41
+ :placeholder="placeholder ? placeholder : $t('sui.select_an_item')"
42
+ class="w-full min-w-[50px] border-none p-0 pl-1 shadow-none outline-none focus:border-none focus:shadow-none focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
43
+ :class="[inputClass]"
44
+ autocomplete="off"
45
+ :disabled="disabled"
46
+ @click="open"
47
47
  @input="onTextInput"
48
48
  @keydown="onTextKeydown"
49
49
  />
@@ -53,74 +53,34 @@
53
53
 
54
54
  <div class="relative">
55
55
  <div
56
- v-show="showDropdown"
57
- class="absolute top-1 z-menu min-h-[110px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md"
56
+ v-if="opened || dropdownShow == 'always'"
57
+ :class="[
58
+ inline
59
+ ? 'relative mt-1'
60
+ : 'absolute top-1 z-menu min-h-[110px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md',
61
+ ]"
58
62
  >
59
- <div
60
- ref="dropdown"
61
- data-scroll-lock-scrollable
62
- class="max-h-[214px] min-h-[75px] w-full overflow-y-auto"
63
- >
64
- <slot v-if="filteredNormalizedOptions.length == 0" name="empty">
65
- <div
66
- class="flex items-center justify-center px-5 py-10 text-center text-slate-600"
67
- >
68
- {{ $t('sui.nothing_found') }}
69
- </div>
70
- </slot>
71
-
72
- <ul v-else class="p-1">
73
- <li
74
- v-for="option in filteredNormalizedOptions"
75
- :key="option.value"
76
- class="block"
77
- >
78
- <button
79
- class="block w-full cursor-pointer appearance-none border-none text-left focus:outline-none"
80
- type="button"
81
- tabindex="-1"
82
- @click="onSelect(option)"
83
- @mousedown.prevent="dontLooseFocus"
84
- >
85
- <slot
86
- name="option"
87
- :option="option.option"
88
- :active="optionActive && optionActive.value == option.value"
89
- >
90
- <div
91
- class="rounded px-2 py-1 text-sm"
92
- :class="optionClass(option)"
93
- >
94
- {{ option.label }}
95
- </div>
96
- </slot>
97
- </button>
98
- </li>
99
- </ul>
100
- </div>
101
-
102
- <div ref="footer">
103
- <div v-if="$slots.footer" class="bg-white">
104
- <slot :options="filteredNormalizedOptions" name="footer" />
105
- </div>
106
- </div>
107
-
108
- <div
109
- v-if="loading"
110
- class="absolute inset-0 h-full w-full space-y-1 bg-white p-2"
63
+ <BaseAutocompleteDropdown
64
+ :selected="normalizedModelValue"
65
+ :options="filteredNormalizedOptions"
66
+ :size="size"
67
+ :loading="loading"
68
+ :loading-bottom="loadingBottom"
69
+ :dropdown-class="inline ? '' : 'p-1'"
70
+ :keywords="keywords"
71
+ @select="onSelect"
72
+ @scroll-bottom="emit('scrollBottom')"
111
73
  >
112
- <div class="space-y-1">
113
- <BaseSkeleton class="h-7 w-full" delay="0ms"></BaseSkeleton>
114
- <BaseSkeleton
115
- class="h-7 w-full opacity-70"
116
- delay="50ms"
117
- ></BaseSkeleton>
118
- <BaseSkeleton
119
- class="h-7 w-full opacity-30"
120
- delay="100ms"
121
- ></BaseSkeleton>
122
- </div>
123
- </div>
74
+ <template #empty="emptyProps">
75
+ <slot name="empty" v-bind="{ ...emptyProps, ...slotProps }" />
76
+ </template>
77
+ <template #option="optionProps">
78
+ <slot name="option" v-bind="{ ...optionProps, ...slotProps }" />
79
+ </template>
80
+ <template #footer="footerProps">
81
+ <slot name="footer" v-bind="{ ...footerProps, ...slotProps }" />
82
+ </template>
83
+ </BaseAutocompleteDropdown>
124
84
  </div>
125
85
  </div>
126
86
  </div>
@@ -130,11 +90,14 @@
130
90
  import { cloneDeep, get } from 'lodash';
131
91
  import { PropType, Ref, ComputedRef } from 'vue';
132
92
  import { NormalizedOption, Option } from '@/types';
133
- import { useInfiniteScroll, useMutationObserver } from '@vueuse/core';
134
- import { useNotificationsStore } from '@/stores/notifications';
135
- import BaseSkeleton from '@/components/BaseSkeleton.vue';
136
93
  import { useHasOptions } from '@/composables/hasOptions';
137
94
  import { useField } from '@/composables/field';
95
+ import { useClickOutside } from '@/composables/clickOutside';
96
+ import { useNotificationsStore } from '@/stores/notifications';
97
+ import BaseAutocompleteDropdown from './BaseAutocompleteDropdown.vue';
98
+
99
+ const i18n = useI18n();
100
+ const notifications = useNotificationsStore();
138
101
 
139
102
  const props = defineProps({
140
103
  modelValue: {
@@ -165,6 +128,10 @@ const props = defineProps({
165
128
  default: false,
166
129
  type: Boolean,
167
130
  },
131
+ loadingBottom: {
132
+ default: false,
133
+ type: Boolean,
134
+ },
168
135
  required: {
169
136
  default: false,
170
137
  type: Boolean,
@@ -185,12 +152,25 @@ const props = defineProps({
185
152
  default: false,
186
153
  type: Boolean,
187
154
  },
155
+ inline: {
156
+ default: false,
157
+ type: Boolean,
158
+ },
159
+ size: {
160
+ default: 'base',
161
+ type: String as PropType<'xs' | 'sm' | 'base'>,
162
+ },
163
+ dropdownShow: {
164
+ default: 'focus',
165
+ type: String as PropType<'focus' | 'always'>,
166
+ },
188
167
  });
189
168
 
190
169
  const emit = defineEmits([
191
170
  'update:modelValue',
192
171
  'typing',
193
- 'focus',
172
+ 'open',
173
+ 'close',
194
174
  'scrollBottom',
195
175
  ]);
196
176
 
@@ -201,17 +181,6 @@ const { hasErrorInternal, emitUpdate } = useField({
201
181
  emit: emit,
202
182
  });
203
183
 
204
- const i18n = useI18n();
205
- const notifications = useNotificationsStore();
206
-
207
- const timerId = ref(0);
208
- const keywords = ref('');
209
- const showDropdown = ref(false);
210
- const inputElement = ref(null) as Ref<HTMLInputElement | null>;
211
- const dropdown = ref(null) as Ref<HTMLElement | null>;
212
- const activeIndex = ref(0);
213
- const selectionToDelete = ref(null) as Ref<NormalizedOption | null>;
214
-
215
184
  const hasOptions = useHasOptions(
216
185
  computed(() => props.modelValue),
217
186
  computed(() => props.options),
@@ -220,33 +189,25 @@ const hasOptions = useHasOptions(
220
189
  computed(() => true)
221
190
  );
222
191
 
192
+ const keywords = ref('');
193
+ const autocomplete = ref(null) as Ref<HTMLElement | null>;
194
+ const inputElement = ref(null) as Ref<HTMLInputElement | null>;
195
+ const shouldFilter = ref(false);
196
+ const opened = ref(false);
197
+ const selectionToDelete = ref(null) as Ref<NormalizedOption | null>;
198
+
223
199
  const isSelected = hasOptions.isSelected;
224
200
  const normalizedOptions = hasOptions.normalizedOptions;
225
201
  const normalizedModelValue = hasOptions.normalizedModelValue as ComputedRef<
226
202
  NormalizedOption[]
227
203
  >;
228
204
 
229
- onMounted(() => {
230
- useInfiniteScroll(
231
- dropdown.value,
232
- () => {
233
- emit('scrollBottom');
234
- },
235
- { distance: 60 }
236
- );
237
- });
238
-
239
- const optionActive = computed(() => {
240
- return (
241
- filteredNormalizedOptions.value[
242
- Math.min(activeIndex.value, filteredNormalizedOptions.value.length - 1)
243
- ] ?? null
244
- );
245
- });
246
-
247
205
  const filteredNormalizedOptions = computed((): NormalizedOption[] => {
248
206
  return normalizedOptions.value
249
207
  .filter((option) => {
208
+ if (shouldFilter.value === false) {
209
+ return true;
210
+ }
250
211
  if (props.filter !== undefined) {
251
212
  return props.filter(option);
252
213
  }
@@ -260,121 +221,61 @@ const filteredNormalizedOptions = computed((): NormalizedOption[] => {
260
221
  });
261
222
  });
262
223
 
263
- function preventUnfocus(elements: HTMLElement[]) {
264
- elements.forEach((e) => {
265
- e.removeEventListener('mousedown', dontLooseFocus);
266
- });
267
- elements.forEach((e) => {
268
- e.addEventListener('mousedown', dontLooseFocus);
269
- });
270
- }
224
+ useClickOutside(autocomplete, () => {
225
+ close();
226
+ });
271
227
 
272
- const dontLooseFocus = (event: Event, next: null | (() => void) = null) => {
273
- event.preventDefault();
274
- inputElement.value?.focus();
275
- if (next) {
276
- next();
228
+ function open() {
229
+ // Always focus as a safety
230
+ focus();
231
+ // Only emit open if value changes
232
+ if (!opened.value) {
233
+ opened.value = true;
234
+ emit('open');
277
235
  }
278
- };
279
-
280
- const onTextFocus = () => {
281
- clearTimeout(timerId.value);
282
- showDropdown.value = true;
283
- emit('focus');
284
- };
236
+ }
285
237
 
286
- const onTextBlur = () => {
287
- timerId.value = setTimeout(() => {
288
- showDropdown.value = false;
289
- }, 10);
290
- };
238
+ function close() {
239
+ shouldFilter.value = false;
240
+ opened.value = false;
241
+ blur();
242
+ emit('close');
243
+ }
291
244
 
292
245
  const onTextInput = (event: Event) => {
293
- activeIndex.value = 0;
294
- selectionToDelete.value = null;
246
+ open();
247
+ shouldFilter.value = true;
295
248
  setKeywords(get(event, 'target.value', '') + '');
296
- dropdown.value?.scrollTo({
297
- top: 0,
298
- });
249
+ emit('typing', keywords.value);
250
+
251
+ selectionToDelete.value = null;
299
252
  };
300
253
 
301
254
  const onTextKeydown = (event: KeyboardEvent) => {
302
255
  const key = event.key;
303
256
 
304
- if (props.loading) {
305
- return;
306
- }
307
-
308
- if (key === 'Backspace' && keywords.value == '') {
309
- attemptRemoveLastSelection();
310
- return;
311
- }
312
-
313
- if (key === 'ArrowDown') {
314
- if (activeIndex.value < filteredNormalizedOptions.value.length - 1) {
315
- activeIndex.value++;
316
- } else {
317
- activeIndex.value = 0;
318
- }
319
- return;
320
- }
257
+ // Prevent default behavior for up/down arrows
321
258
 
322
259
  if (key === 'ArrowUp') {
323
- if (activeIndex.value > 0) {
324
- activeIndex.value--;
325
- } else {
326
- activeIndex.value = Math.max(
327
- 0,
328
- filteredNormalizedOptions.value.length - 1
329
- );
330
- }
260
+ event.preventDefault();
331
261
  return;
332
262
  }
333
263
 
334
- if (key === 'Enter') {
264
+ if (key === 'ArrowDown') {
335
265
  event.preventDefault();
336
- if (optionActive.value) {
337
- addOption(optionActive.value);
338
- }
339
266
  return;
340
267
  }
341
- };
342
-
343
- const optionClass = (option: NormalizedOption) => {
344
- const active = optionActive.value && optionActive.value.value == option.value;
345
- const selected = isSelected(option);
346
-
347
- if (selected) {
348
- if (active) {
349
- return 'bg-blue-600 hover:bg-blue-700 text-white';
350
- }
351
-
352
- return 'bg-blue-500 hover:bg-blue-600 text-white';
353
- }
354
268
 
355
- if (active) {
356
- return 'bg-slate-200 hover:bg-slate-300';
357
- }
358
-
359
- return 'bg-white hover:bg-slate-100';
360
- };
361
-
362
- const selectionClass = (selection: NormalizedOption): string => {
363
- if (
364
- selectionToDelete.value &&
365
- selectionToDelete.value.value == selection.value
366
- ) {
367
- return 'bg-red-200 border-red-300 text-red-800';
269
+ // Attempt to remove last selection on backspace
270
+ if (key === 'Backspace' && keywords.value == '') {
271
+ attemptRemoveLastSelection();
272
+ return;
368
273
  }
369
- return 'bg-slate-200 border-slate-300';
370
274
  };
371
275
 
372
- const onSelect = (normalizedModelValue: NormalizedOption) => {
373
- addOption(normalizedModelValue);
374
- inputElement.value?.blur();
375
- };
276
+ const onSelect = (option: NormalizedOption) => {
277
+ focus();
376
278
 
377
- const addOption = (option: NormalizedOption) => {
378
279
  if (props.max && normalizedModelValue.value.length >= props.max) {
379
280
  notifications.push({
380
281
  title: i18n.t('sui.whoops'),
@@ -390,11 +291,37 @@ const addOption = (option: NormalizedOption) => {
390
291
  return;
391
292
  }
392
293
 
393
- beforeAddOption();
394
-
294
+ selectionToDelete.value = null;
395
295
  update([...normalizedModelValue.value, option]);
396
-
397
296
  setKeywords('');
297
+ emit('typing', keywords.value);
298
+ };
299
+
300
+ function update(value: NormalizedOption[]) {
301
+ // Re-activate filter
302
+ shouldFilter.value = false;
303
+ // Emit update
304
+ emitUpdate(value.map((v) => v.option));
305
+ }
306
+
307
+ function setKeywords(input: string) {
308
+ keywords.value = input;
309
+ }
310
+
311
+ function focus() {
312
+ inputElement.value?.focus();
313
+ }
314
+
315
+ function blur() {
316
+ inputElement.value?.blur();
317
+ }
318
+
319
+ const slotProps = {
320
+ focus,
321
+ blur,
322
+ open,
323
+ close,
324
+ keywords: computed(() => keywords.value),
398
325
  };
399
326
 
400
327
  const attemptRemoveLastSelection = () => {
@@ -418,61 +345,65 @@ const attemptRemoveLastSelection = () => {
418
345
  };
419
346
 
420
347
  const removeOption = (option: NormalizedOption) => {
348
+ focus();
421
349
  let newModelValue = cloneDeep(normalizedModelValue.value);
422
350
  newModelValue = newModelValue.filter((v) => v.value != option.value);
423
351
  update(newModelValue);
424
352
  };
425
353
 
426
- function update(value: NormalizedOption[]) {
427
- emitUpdate(value.map((v) => v.option));
428
- afterUpdate();
429
- }
430
-
431
- const beforeAddOption = () => {
432
- selectionToDelete.value = null;
433
- clearInput();
434
- };
435
-
436
- const afterUpdate = () => {
437
- validateActiveIndex();
438
- };
354
+ // Element Classes
439
355
 
440
- const clearInput = () => {
441
- setKeywords('');
442
- };
356
+ const wrapperClass = computed(() => {
357
+ if (props.size == 'xs') {
358
+ return 'min-h-[34px]';
359
+ }
360
+ if (props.size == 'sm') {
361
+ return 'min-h-[38px]';
362
+ }
363
+ return 'min-h-[42px]';
364
+ });
443
365
 
444
- const validateActiveIndex = () => {
445
- // Wait for filteredOptions to update
446
- nextTick(() => {
447
- activeIndex.value = Math.max(
448
- 0,
449
- Math.min(activeIndex.value, filteredNormalizedOptions.value.length - 1)
450
- );
451
- });
452
- };
366
+ const inputClass = computed(() => {
367
+ const base = 'h-[32px] text-base';
368
+ if (props.size == 'xs') {
369
+ return base + ' xs:text-xs xs:h-[22px]';
370
+ }
371
+ if (props.size == 'sm') {
372
+ return base + ' xs:text-sm xs:h-[28px]';
373
+ }
374
+ return base;
375
+ });
453
376
 
454
- const setKeywords = (input: string) => {
455
- keywords.value = input;
456
- emit('typing', input);
377
+ const selectionClass = (selection: NormalizedOption): string => {
378
+ if (
379
+ selectionToDelete.value &&
380
+ selectionToDelete.value.value == selection.value
381
+ ) {
382
+ return 'bg-red-200 border-red-300 text-red-800';
383
+ }
384
+ return 'bg-slate-200 border-slate-300';
457
385
  };
458
386
 
459
- const footer = ref(null) as Ref<HTMLDivElement | null>;
460
-
461
- function preventUnfocusOnFooter() {
462
- const elements = (footer.value?.querySelectorAll('button, a') ??
463
- []) as HTMLElement[];
464
- preventUnfocus(elements);
465
- }
466
-
467
- onMounted(() => {
468
- preventUnfocusOnFooter();
387
+ const selectionLabelClass = computed((): string => {
388
+ let base = 'py-[5px] pl-[0.75em] text-sm';
389
+ props.disabled ? (base += ' pr-[0.75em]') : (base += ' pr-1');
390
+ if (props.size == 'xs') {
391
+ const classes = base + ' xs:py-[3px] xs:pl-2 xs:text-xs';
392
+ return classes;
393
+ }
394
+ if (props.size == 'sm') {
395
+ const classes = base + ' xs:py-[3px] xs:pl-2 xs:text-sm';
396
+ return classes;
397
+ }
398
+ const classes = base;
399
+ return classes;
469
400
  });
470
401
 
471
- useMutationObserver(
472
- footer,
473
- () => {
474
- preventUnfocusOnFooter();
475
- },
476
- { attributes: false, childList: true }
477
- );
402
+ defineExpose({
403
+ focus,
404
+ blur,
405
+ close,
406
+ open,
407
+ setKeywords,
408
+ });
478
409
  </script>
@@ -1,6 +1,6 @@
1
1
  import BaseTagAutocompleteFetch from './BaseTagAutocompleteFetch.vue';
2
2
  import ShowValue from '@/../.storybook/components/ShowValue.vue';
3
- import { createFieldStory, options } from '../../.storybook/utils';
3
+ import { createFieldStory } from '../../.storybook/utils';
4
4
  import BaseAppNotifications from './BaseAppNotifications.vue';
5
5
 
6
6
  export default {
@@ -58,7 +58,7 @@ Maximum.args = {
58
58
 
59
59
  export const SlotOption = (args) => {
60
60
  return {
61
- components: {},
61
+ components: { BaseTagAutocompleteFetch },
62
62
  setup() {
63
63
  const value = ref([]);
64
64
  return { args, value };
@@ -91,7 +91,7 @@ export const SlotOption = (args) => {
91
91
 
92
92
  export const SlotFooter = (args) => {
93
93
  return {
94
- components: {},
94
+ components: { BaseTagAutocompleteFetch },
95
95
  setup() {
96
96
  const value = ref([]);
97
97
  function onClick() {
@@ -118,7 +118,7 @@ export const SlotFooter = (args) => {
118
118
 
119
119
  export const SlotEmpty = (args) => {
120
120
  return {
121
- components: {},
121
+ components: { BaseTagAutocompleteFetch },
122
122
  setup() {
123
123
  const value = ref([]);
124
124
  return { args, value };
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <BaseTagAutocomplete
3
3
  :loading="showLoading && page == 1"
4
+ :loading-bottom="showLoading && page > 1"
4
5
  :model-value="modelValue"
5
6
  :disabled="disabled"
6
7
  :placeholder="placeholder"
@@ -10,7 +11,7 @@
10
11
  :has-error="hasError"
11
12
  :max="max"
12
13
  :filter="() => true"
13
- @focus="onFocus"
14
+ @open="onOpen"
14
15
  @typing="onTyping"
15
16
  @scroll-bottom="scrollBottom"
16
17
  @update:model-value="$emit('update:modelValue', $event)"
@@ -37,7 +38,7 @@
37
38
  </template>
38
39
 
39
40
  <script lang="ts" setup>
40
- import { debounce } from 'lodash';
41
+ import { debounce, throttle } from 'lodash';
41
42
  import { config } from '@/index';
42
43
  import { PropType, Ref } from 'vue';
43
44
  import { Option } from '@/types';
@@ -109,18 +110,22 @@ const onTyping = (query: string) => {
109
110
  }
110
111
  };
111
112
 
112
- const onFocus = () => {
113
+ const onOpen = () => {
113
114
  if (!firstSearch.value) {
114
115
  search();
115
116
  }
116
117
  };
117
118
 
118
- const scrollBottom = () => {
119
+ const scrollBottom = throttle(() => {
120
+ if (fetching.value) {
121
+ return;
122
+ }
123
+
119
124
  if (!reachedEnd.value) {
120
125
  page.value++;
121
126
  search();
122
127
  }
123
- };
128
+ }, 500);
124
129
 
125
130
  const search = () => {
126
131
  if (fetching.value) {
@@ -6,7 +6,7 @@ interface UseClickOutsideOptions {
6
6
 
7
7
  export function useClickOutside(
8
8
  element: MaybeElementRef,
9
- callback: (outside: boolean) => void,
9
+ callback: () => void,
10
10
  options: UseClickOutsideOptions = {}
11
11
  ) {
12
12
  function cleanup() {
@@ -44,7 +44,9 @@ export function useClickOutside(
44
44
 
45
45
  const outside = !contains && !containsIncludes;
46
46
 
47
- callback(outside);
47
+ if (outside) {
48
+ callback();
49
+ }
48
50
  }
49
51
 
50
52
  const stop = () => {