sprintify-ui 0.0.77 → 0.0.79

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,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div ref="autocomplete">
3
3
  <div class="relative">
4
4
  <div class="relative">
5
5
  <input
@@ -9,27 +9,38 @@
9
9
  :placeholder="
10
10
  placeholder ? placeholder : $t('sui.autocomplete_placeholder')
11
11
  "
12
- class="w-full rounded pl-9 disabled:cursor-not-allowed disabled:text-slate-300"
13
- :class="[hasErrorInternal ? 'border-red-600' : 'border-slate-300']"
12
+ class="w-full rounded disabled:cursor-not-allowed disabled:text-slate-300"
13
+ :class="[
14
+ hasErrorInternal ? 'border-red-600' : 'border-slate-300',
15
+ inputClass,
16
+ !visibleFocus
17
+ ? [
18
+ 'focus:ring-0',
19
+ hasErrorInternal
20
+ ? 'focus:border-red-600'
21
+ : 'focus:border-slate-300',
22
+ ]
23
+ : '',
24
+ ]"
14
25
  autocomplete="off"
15
26
  :disabled="disabled"
16
- @focus="onTextFocus"
17
- @blur="onTextBlur"
18
27
  @input="onTextInput"
19
28
  @keydown="onTextKeydown"
20
29
  />
21
30
  <div
22
- class="pointer-events-none absolute top-0 left-0 flex h-full items-center justify-center pl-2.5"
31
+ class="pointer-events-none absolute top-0 left-0 flex h-full items-center justify-center"
32
+ :class="[iconWrapClass]"
23
33
  >
24
34
  <BaseIcon
25
- class="h-5 w-5 text-slate-400"
35
+ class="text-slate-400"
36
+ :class="[iconClass]"
26
37
  icon="heroicons:magnifying-glass-solid"
27
38
  />
28
39
  </div>
29
40
  </div>
30
41
 
31
42
  <div
32
- v-if="normalizedModelValue && !disabled"
43
+ v-if="normalizedModelValue && !disabled && modelValueShow"
33
44
  class="absolute top-0 right-0 flex h-full items-center p-1"
34
45
  >
35
46
  <button
@@ -39,7 +50,8 @@
39
50
  >
40
51
  <BaseIcon
41
52
  icon="heroicons:x-mark"
42
- class="h-5 w-5 text-slate-500 group-hover:text-slate-700"
53
+ class="text-slate-500 group-hover:text-slate-700"
54
+ :class="[iconClass]"
43
55
  />
44
56
  </button>
45
57
  </div>
@@ -47,8 +59,13 @@
47
59
 
48
60
  <div class="relative">
49
61
  <div
50
- v-show="showDropdown"
51
- class="absolute top-1 z-menu min-h-[110px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md"
62
+ v-show="opened || dropdownShow == 'always'"
63
+ class="min-h-[110px] w-full overflow-hidden"
64
+ :class="[
65
+ inline
66
+ ? 'relative'
67
+ : 'absolute top-1 z-menu rounded border border-slate-300 bg-white shadow-md',
68
+ ]"
52
69
  >
53
70
  <div
54
71
  ref="dropdown"
@@ -62,9 +79,9 @@
62
79
  </div>
63
80
  </slot>
64
81
 
65
- <ul v-else class="p-1">
82
+ <ul v-else :class="[inline ? 'p-0 pt-1' : 'p-1']">
66
83
  <li
67
- v-for="option in filteredNormalizedOptions"
84
+ v-for="(option, index) in filteredNormalizedOptions"
68
85
  :key="option.value"
69
86
  class="block"
70
87
  >
@@ -73,7 +90,7 @@
73
90
  type="button"
74
91
  tabindex="-1"
75
92
  @click="onSelect(option)"
76
- @mousedown.prevent="dontLooseFocus"
93
+ @mouseenter="selectionIndex = index"
77
94
  >
78
95
  <slot
79
96
  name="option"
@@ -82,10 +99,19 @@
82
99
  :active="optionActive && optionActive.value == option.value"
83
100
  >
84
101
  <div
85
- class="rounded px-2 py-1 text-sm"
86
- :class="optionClass(option)"
102
+ class="flex items-center rounded px-2 py-1 text-sm"
103
+ :class="[optionClass(option), optionSizeClass]"
87
104
  >
88
- {{ option.label }}
105
+ <div class="grow">
106
+ {{ option.label }}
107
+ </div>
108
+ <div class="shrink-0">
109
+ <BaseIcon
110
+ v-if="isSelected(option)"
111
+ icon="heroicons:check-20-solid"
112
+ :class="iconClass"
113
+ ></BaseIcon>
114
+ </div>
89
115
  </div>
90
116
  </slot>
91
117
  </button>
@@ -93,12 +119,11 @@
93
119
  </ul>
94
120
  </div>
95
121
 
96
- <div ref="footer">
122
+ <div>
97
123
  <div v-if="$slots.footer" class="bg-white">
98
124
  <slot
99
125
  :options="filteredNormalizedOptions"
100
126
  :keywords="keywords"
101
- :hide-dropdown="hideDropdown"
102
127
  name="footer"
103
128
  />
104
129
  </div>
@@ -129,7 +154,7 @@
129
154
  import { get } from 'lodash';
130
155
  import { PropType, Ref, ComputedRef } from 'vue';
131
156
  import { NormalizedOption, Option } from '@/types';
132
- import { useInfiniteScroll, useMutationObserver } from '@vueuse/core';
157
+ import { useInfiniteScroll } from '@vueuse/core';
133
158
  import BaseSkeleton from '@/components/BaseSkeleton.vue';
134
159
  import { useHasOptions } from '@/composables/hasOptions';
135
160
  import { useField } from '@/composables/field';
@@ -180,14 +205,37 @@ const props = defineProps({
180
205
  default: false,
181
206
  type: Boolean,
182
207
  },
208
+ inline: {
209
+ default: false,
210
+ type: Boolean,
211
+ },
212
+ size: {
213
+ default: 'base',
214
+ type: String as PropType<'xs' | 'sm' | 'base'>,
215
+ },
216
+ dropdownShow: {
217
+ default: 'focus',
218
+ type: String as PropType<'focus' | 'always'>,
219
+ },
220
+ modelValueShow: {
221
+ default: true,
222
+ type: Boolean,
223
+ },
224
+ visibleFocus: {
225
+ default: true,
226
+ type: Boolean,
227
+ },
183
228
  });
184
229
 
185
230
  const emit = defineEmits([
186
231
  'update:modelValue',
187
232
  'typing',
233
+ 'blur',
188
234
  'focus',
189
235
  'scrollBottom',
190
236
  'clear',
237
+ 'open',
238
+ 'close',
191
239
  ]);
192
240
 
193
241
  const { hasErrorInternal, emitUpdate } = useField({
@@ -197,12 +245,14 @@ const { hasErrorInternal, emitUpdate } = useField({
197
245
  emit: emit,
198
246
  });
199
247
 
200
- const timerId = ref(0);
248
+ let timerId = 0;
201
249
  const keywords = ref('');
202
- const showDropdown = ref(false);
203
250
  const selectionIndex = ref(0);
251
+ const autocomplete = ref(null) as Ref<HTMLElement | null>;
204
252
  const inputElement = ref(null) as Ref<HTMLInputElement | null>;
205
253
  const dropdown = ref(null) as Ref<HTMLElement | null>;
254
+ const shouldFilter = ref(false);
255
+ const opened = ref(false);
206
256
 
207
257
  const hasOptions = useHasOptions(
208
258
  computed(() => props.modelValue),
@@ -217,16 +267,6 @@ const normalizedOptions = hasOptions.normalizedOptions;
217
267
  const normalizedModelValue =
218
268
  hasOptions.normalizedModelValue as ComputedRef<NormalizedOption | null>;
219
269
 
220
- onMounted(() => {
221
- useInfiniteScroll(
222
- dropdown.value,
223
- () => {
224
- emit('scrollBottom');
225
- },
226
- { distance: 60 }
227
- );
228
- });
229
-
230
270
  const optionActive = computed(() => {
231
271
  return (
232
272
  filteredNormalizedOptions.value[
@@ -239,16 +279,22 @@ const optionActive = computed(() => {
239
279
  watch(
240
280
  () => normalizedModelValue.value,
241
281
  () => {
282
+ if (props.modelValueShow === false) {
283
+ return;
284
+ }
242
285
  if (normalizedModelValue.value) {
243
- keywords.value = normalizedModelValue.value?.label;
286
+ setKeywords(normalizedModelValue.value?.label);
244
287
  } else {
245
- keywords.value = '';
288
+ setKeywords('');
246
289
  }
247
290
  },
248
291
  { immediate: true }
249
292
  );
250
293
 
251
294
  const filteredNormalizedOptions = computed((): NormalizedOption[] => {
295
+ if (shouldFilter.value === false) {
296
+ return normalizedOptions.value;
297
+ }
252
298
  return normalizedOptions.value.filter((option) => {
253
299
  if (props.filter !== undefined) {
254
300
  return props.filter(option);
@@ -260,40 +306,69 @@ const filteredNormalizedOptions = computed((): NormalizedOption[] => {
260
306
  });
261
307
  });
262
308
 
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
- }
309
+ onMounted(() => {
310
+ window.addEventListener('mousedown', onMouseDown);
311
+ });
271
312
 
272
- const dontLooseFocus = (event: Event) => {
273
- inputElement.value?.focus();
274
- event.preventDefault();
275
- };
313
+ onBeforeMount(() => {
314
+ window.removeEventListener('mousedown', onMouseDown);
315
+ });
276
316
 
277
- const onTextFocus = () => {
278
- clearTimeout(timerId.value);
279
- showDropdown.value = true;
280
- emit('focus');
281
- };
317
+ function onMouseDown(event: MouseEvent) {
318
+ if (!autocomplete.value) {
319
+ return;
320
+ }
282
321
 
283
- // If keywords is changed but no new selection was made,
284
- // update keywords to original value
285
- const onTextBlur = () => {
286
- timerId.value = setTimeout(() => {
287
- showDropdown.value = false;
288
- if (normalizedModelValue.value) {
289
- setKeywordsWithoutEvent(normalizedModelValue.value.label);
322
+ // Get the element that was clicked
323
+ const clickedElement = event.target as HTMLElement | null;
324
+
325
+ if (!clickedElement) {
326
+ return;
327
+ }
328
+
329
+ // If the element that was not clicked on the input,
330
+ // prevent default
331
+ if (clickedElement !== inputElement.value) {
332
+ event.preventDefault();
333
+ }
334
+
335
+ // `el` is the element you're detecting clicks outside of
336
+ if (autocomplete.value.contains(clickedElement)) {
337
+ open();
338
+ } else {
339
+ close();
340
+ }
341
+ }
342
+
343
+ function open() {
344
+ clearInterval(timerId);
345
+ // Always focus as a safety
346
+ focus();
347
+ // Only emit open if value changes
348
+ if (!opened.value) {
349
+ opened.value = true;
350
+ emit('open');
351
+ }
352
+ }
353
+
354
+ function close() {
355
+ opened.value = false;
356
+ blur();
357
+ timerId = setTimeout(() => {
358
+ // If no valid modelValue is set on close, set the keywords to the original value
359
+ if (props.modelValueShow && normalizedModelValue.value) {
360
+ setKeywords(normalizedModelValue.value.label);
290
361
  }
291
362
  }, 10);
292
- };
363
+ emit('close');
364
+ }
293
365
 
294
366
  const onTextInput = (event: Event) => {
367
+ open();
368
+ shouldFilter.value = true;
295
369
  selectionIndex.value = 0;
296
370
  setKeywords(get(event, 'target.value') + '');
371
+ emit('typing', keywords.value);
297
372
  dropdown.value?.scrollTo({
298
373
  top: 0,
299
374
  });
@@ -311,6 +386,7 @@ const onTextKeydown = (event: KeyboardEvent) => {
311
386
  }
312
387
 
313
388
  if (key === 'ArrowDown') {
389
+ event.preventDefault();
314
390
  if (selectionIndex.value < filteredNormalizedOptions.value.length - 1) {
315
391
  selectionIndex.value++;
316
392
  } else {
@@ -320,6 +396,7 @@ const onTextKeydown = (event: KeyboardEvent) => {
320
396
  }
321
397
 
322
398
  if (key === 'ArrowUp') {
399
+ event.preventDefault();
323
400
  if (selectionIndex.value > 0) {
324
401
  selectionIndex.value--;
325
402
  } else {
@@ -340,74 +417,112 @@ const onTextKeydown = (event: KeyboardEvent) => {
340
417
  }
341
418
  };
342
419
 
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
- }
420
+ const clear = () => {
421
+ update(null);
422
+ emit('clear');
423
+ };
351
424
 
352
- return 'bg-blue-500 hover:bg-blue-600 text-white';
425
+ function onSelect(normalizedModelValue: Option | null | undefined) {
426
+ focus();
427
+ update(normalizedModelValue);
428
+ if (props.dropdownShow == 'focus') {
429
+ close();
353
430
  }
431
+ }
354
432
 
355
- if (active) {
356
- return 'bg-slate-200 hover:bg-slate-300';
433
+ function update(normalizedSelection: Option | null | undefined) {
434
+ const selection = normalizedSelection ? normalizedSelection.option : null;
435
+ if (selection) {
436
+ const index = filteredNormalizedOptions.value.findIndex(
437
+ (option) => option.value == selection.value
438
+ );
439
+ selectionIndex.value = index;
357
440
  }
441
+ shouldFilter.value = false;
442
+ emitUpdate(selection);
443
+ }
358
444
 
359
- return 'bg-white hover:bg-slate-100';
360
- };
445
+ function setKeywords(input: string) {
446
+ keywords.value = input;
447
+ }
361
448
 
362
- const clear = () => {
363
- emit('clear');
364
- setKeywordsWithoutEvent('');
365
- update(null);
449
+ function focus() {
366
450
  inputElement.value?.focus();
367
- };
368
-
369
- const onSelect = (normalizedModelValue: Option | null | undefined) => {
370
- update(normalizedModelValue);
371
- };
451
+ }
372
452
 
373
- function hideDropdown() {
453
+ function blur() {
374
454
  inputElement.value?.blur();
375
455
  }
376
456
 
377
- const update = (normalizedSelection: Option | null | undefined) => {
378
- const selection = normalizedSelection ? normalizedSelection.option : null;
379
- if (normalizedSelection) {
380
- setKeywordsWithoutEvent(normalizedSelection.label);
457
+ // Element Classes
458
+
459
+ const optionClass = (option: NormalizedOption) => {
460
+ const active = optionActive.value && optionActive.value.value == option.value;
461
+
462
+ if (active) {
463
+ return 'bg-slate-200';
381
464
  }
382
- emitUpdate(selection);
383
- };
384
465
 
385
- const setKeywordsWithoutEvent = (input: string) => {
386
- keywords.value = input;
466
+ return 'bg-white';
387
467
  };
388
468
 
389
- const setKeywords = (input: string) => {
390
- keywords.value = input;
391
- emit('typing', input);
392
- };
469
+ const optionSizeClass = computed(() => {
470
+ if (props.size == 'xs') {
471
+ return 'text-xs';
472
+ }
473
+ if (props.size == 'sm') {
474
+ return 'text-sm';
475
+ }
476
+ return 'text-sm';
477
+ });
478
+
479
+ const inputClass = computed(() => {
480
+ if (props.size == 'xs') {
481
+ return 'xs:text-xs xs:pl-7 text-base pl-9';
482
+ }
483
+ if (props.size == 'sm') {
484
+ return 'xs:text-sm xs:pl-8 text-base pl-9';
485
+ }
486
+ return 'text-base pl-9';
487
+ });
393
488
 
394
- const footer = ref(null) as Ref<HTMLDivElement | null>;
489
+ const iconClass = computed(() => {
490
+ if (props.size == 'xs') {
491
+ return 'xs:h-4 xs:w-4 h-5 w-5';
492
+ }
493
+ if (props.size == 'sm') {
494
+ return 'xs:h-5 xs:w-5 h-5 w-5';
495
+ }
496
+ return 'h-5 w-5';
497
+ });
395
498
 
396
- function preventUnfocusOnFooter() {
397
- const elements = (footer.value?.querySelectorAll('button, a') ??
398
- []) as HTMLElement[];
399
- preventUnfocus(elements);
400
- }
499
+ const iconWrapClass = computed(() => {
500
+ if (props.size == 'xs') {
501
+ return 'xs:pl-2 pl-2.5';
502
+ }
503
+ if (props.size == 'sm') {
504
+ return 'xs:pl-2 pl-2.5';
505
+ }
506
+ return 'pl-2.5';
507
+ });
508
+
509
+ // Infinite scroll
401
510
 
402
511
  onMounted(() => {
403
- preventUnfocusOnFooter();
512
+ useInfiniteScroll(
513
+ dropdown.value,
514
+ () => {
515
+ emit('scrollBottom');
516
+ },
517
+ { distance: 60 }
518
+ );
404
519
  });
405
520
 
406
- useMutationObserver(
407
- footer,
408
- () => {
409
- preventUnfocusOnFooter();
410
- },
411
- { attributes: false, childList: true, subtree: true }
412
- );
521
+ defineExpose({
522
+ focus,
523
+ blur,
524
+ close,
525
+ open,
526
+ setKeywords,
527
+ });
413
528
  </script>
@@ -3,9 +3,19 @@ import ShowValue from '@/../.storybook/components/ShowValue.vue';
3
3
  import { options } from '@/../.storybook/utils';
4
4
  import { createFieldStory } from '../../.storybook/utils';
5
5
 
6
+ const sizes = ['xs', 'sm', 'base'];
7
+
6
8
  export default {
7
9
  title: 'Form/BaseAutocompleteFetch',
8
10
  component: BaseAutocompleteFetch,
11
+ argTypes: {
12
+ size: {
13
+ control: {
14
+ type: 'select',
15
+ options: sizes,
16
+ },
17
+ },
18
+ },
9
19
  args: {
10
20
  url: 'https://effettandem.com/api/content/articles',
11
21
  labelKey: 'title',
@@ -29,6 +39,17 @@ const Template = (args) => ({
29
39
  export const Demo = Template.bind({});
30
40
  Demo.args = {};
31
41
 
42
+ export const AlwaysShowDropdown = Template.bind({});
43
+ AlwaysShowDropdown.args = {
44
+ inline: true,
45
+ dropdownShow: 'always',
46
+ };
47
+
48
+ export const NoFocus = Template.bind({});
49
+ NoFocus.args = {
50
+ visibleFocus: false,
51
+ };
52
+
32
53
  export const Disabled = Template.bind({});
33
54
  Disabled.args = {
34
55
  labelKey: 'label',
@@ -37,6 +58,25 @@ Disabled.args = {
37
58
  disabled: true,
38
59
  };
39
60
 
61
+ export const Inline = Template.bind({});
62
+ Inline.args = {
63
+ inline: true,
64
+ };
65
+
66
+ export const Sizes = (args) => ({
67
+ components: { BaseAutocompleteFetch },
68
+ setup() {
69
+ const value = ref(null);
70
+ return { args, sizes, value };
71
+ },
72
+ template: `
73
+ <div v-for="size in sizes" class="mb-1">
74
+ <p class="text-xs text-slate-600 leading-tight">{{ size }}</p>
75
+ <BaseAutocompleteFetch v-model="value" v-bind="args" :size="size"></BaseAutocompleteFetch>
76
+ </div>
77
+ `,
78
+ });
79
+
40
80
  export const SlotOption = (args) => {
41
81
  return {
42
82
  components: { BaseAutocompleteFetch },
@@ -10,9 +10,15 @@
10
10
  :value-key="valueKey"
11
11
  :label-key="labelKey"
12
12
  :has-error="hasError"
13
+ :required="required"
14
+ :size="size"
15
+ :inline="inline"
16
+ :dropdown-show="dropdownShow"
17
+ :model-value-show="modelValueShow"
18
+ :visible-focus="visibleFocus"
13
19
  :filter="() => true"
14
20
  @clear="onClear"
15
- @focus="onFocus"
21
+ @open="onOpen"
16
22
  @typing="onTyping"
17
23
  @scroll-bottom="scrollBottom"
18
24
  @update:model-value="$emit('update:modelValue', $event)"
@@ -45,6 +51,15 @@ import { PropType, Ref } from 'vue';
45
51
  import { Option } from '@/types';
46
52
  import BaseAutocomplete from './BaseAutocomplete.vue';
47
53
 
54
+ /**
55
+ * Behavior notes
56
+ *
57
+ * - When the user types, the component will fetch the data from the API.
58
+ * - When the user scrolls to the bottom, the component will fetch the next page.
59
+ * - When the user clears the input, the component will NOT re-fetch the data if the current query is already empty.
60
+ * - When a value is selected, the component will NOT re-fetch the data.
61
+ */
62
+
48
63
  const props = defineProps({
49
64
  modelValue: {
50
65
  default: undefined,
@@ -86,6 +101,26 @@ const props = defineProps({
86
101
  default: false,
87
102
  type: Boolean,
88
103
  },
104
+ inline: {
105
+ default: false,
106
+ type: Boolean,
107
+ },
108
+ size: {
109
+ default: 'base',
110
+ type: String as PropType<'xs' | 'sm' | 'base'>,
111
+ },
112
+ dropdownShow: {
113
+ default: 'focus',
114
+ type: String as PropType<'focus' | 'always'>,
115
+ },
116
+ modelValueShow: {
117
+ default: true,
118
+ type: Boolean,
119
+ },
120
+ visibleFocus: {
121
+ default: true,
122
+ type: Boolean,
123
+ },
89
124
  });
90
125
 
91
126
  const emit = defineEmits([
@@ -112,15 +147,17 @@ const onTyping = (query: string) => {
112
147
  debouncedSearch();
113
148
  };
114
149
 
115
- const onFocus = () => {
150
+ const onOpen = () => {
116
151
  if (!firstSearch.value) {
117
152
  search();
118
153
  }
119
154
  };
120
155
 
121
156
  const onClear = () => {
122
- keywords.value = '';
123
- search();
157
+ if (keywords.value != '') {
158
+ keywords.value = '';
159
+ search();
160
+ }
124
161
  emit('clear');
125
162
  };
126
163
 
@@ -131,7 +168,7 @@ const scrollBottom = () => {
131
168
  }
132
169
  };
133
170
 
134
- const search = () => {
171
+ function search() {
135
172
  if (fetching.value) {
136
173
  return;
137
174
  }
@@ -165,7 +202,7 @@ const search = () => {
165
202
  fetching.value = false;
166
203
  showLoading.value = false;
167
204
  });
168
- };
205
+ }
169
206
 
170
207
  const debouncedSearch = debounce(() => {
171
208
  search();