sprintify-ui 0.0.12 → 0.0.13

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 (42) hide show
  1. package/README.md +8 -7
  2. package/dist/sprintify-ui.es.js +4511 -3696
  3. package/dist/style.css +1 -1
  4. package/dist/tailwindcss/index.js +1 -2
  5. package/dist/types/src/components/BaseCharacterCounter.vue.d.ts +4 -4
  6. package/dist/types/src/components/BaseHasMany.vue.d.ts +277 -0
  7. package/dist/types/src/components/{BaseMediaLibraryItem.vue.d.ts → BaseMediaItem.vue.d.ts} +26 -4
  8. package/dist/types/src/components/BaseMediaLibrary.vue.d.ts +23 -15
  9. package/dist/types/src/components/BaseMediaPreview.vue.d.ts +97 -0
  10. package/dist/types/src/components/BaseSideNavigationItem.vue.d.ts +20 -1
  11. package/dist/types/src/components/BaseTabItem.vue.d.ts +20 -1
  12. package/dist/types/src/components/BaseTagAutocomplete.vue.d.ts +25 -17
  13. package/dist/types/src/components/BaseTagAutocompleteFetch.vue.d.ts +37 -21
  14. package/dist/types/src/components/index.d.ts +10 -4
  15. package/package.json +1 -1
  16. package/src/components/BaseAppDialogs.vue +2 -2
  17. package/src/components/BaseAppNotifications.vue +1 -1
  18. package/src/components/BaseAutocomplete.vue +16 -18
  19. package/src/components/BaseBelongsTo.vue +1 -0
  20. package/src/components/BaseClipboard.vue +1 -1
  21. package/src/components/BaseHasMany.vue +92 -0
  22. package/src/components/BaseMediaItem.stories.js +41 -0
  23. package/src/components/BaseMediaItem.vue +71 -0
  24. package/src/components/BaseMediaLibrary.stories.js +80 -0
  25. package/src/components/BaseMediaLibrary.vue +67 -68
  26. package/src/components/BaseMediaPreview.stories.js +72 -0
  27. package/src/components/BaseMediaPreview.vue +90 -0
  28. package/src/components/BaseMenu.vue +1 -1
  29. package/src/components/BaseSideNavigationItem.vue +11 -3
  30. package/src/components/BaseTabItem.vue +13 -3
  31. package/src/components/BaseTable.vue +2 -2
  32. package/src/components/BaseTagAutocomplete.stories.js +129 -0
  33. package/src/components/BaseTagAutocomplete.vue +155 -57
  34. package/src/components/BaseTagAutocompleteFetch.stories.js +130 -0
  35. package/src/components/BaseTagAutocompleteFetch.vue +36 -25
  36. package/src/components/HasMany.stories.js +135 -0
  37. package/src/components/index.ts +18 -6
  38. package/src/lang/en.json +1 -1
  39. package/src/lang/fr.json +1 -1
  40. package/dist/types/src/components/BasePaginationSimple.vue.d.ts +0 -25
  41. package/src/components/BaseMediaLibraryItem.vue +0 -92
  42. package/src/components/BasePaginationSimple.vue +0 -60
@@ -0,0 +1,129 @@
1
+ import BaseTagAutocomplete from './BaseTagAutocomplete.vue';
2
+
3
+ export default {
4
+ title: 'Form/BaseTagAutocomplete',
5
+ component: BaseTagAutocomplete,
6
+ argTypes: {},
7
+ args: {
8
+ labelKey: 'label',
9
+ valueKey: 'value',
10
+ options: [
11
+ { label: 'Dark Vader', value: 'dark_vader' },
12
+ { label: 'Darth Maul', value: 'darth_maul' },
13
+ { label: 'Dark Sidious', value: 'dark_sidious' },
14
+ ],
15
+ },
16
+ decorators: [() => ({ template: '<div class="mb-36"><story/></div>' })],
17
+ };
18
+
19
+ const Template = (args) => ({
20
+ components: { BaseTagAutocomplete },
21
+ setup() {
22
+ const value = ref(null);
23
+ return { args, value };
24
+ },
25
+ template: `
26
+ <BaseTagAutocomplete v-model="value" v-bind="args"></BaseTagAutocomplete>
27
+ <p class="mt-5 text-sm">Value: <span class="bg-slate-200 font-mono px-1 py-px rounded">{{ value ?? 'NULL' }}</span></p>
28
+ `,
29
+ });
30
+
31
+ export const Demo = Template.bind({});
32
+ Demo.args = {};
33
+
34
+ export const Disabled = Template.bind({});
35
+ Disabled.args = {
36
+ options: [],
37
+ disabled: true,
38
+ modelValue: [{ label: 'Dark Maul', value: '1' }],
39
+ };
40
+
41
+ export const Loading = Template.bind({});
42
+ Loading.args = {
43
+ loading: true,
44
+ };
45
+
46
+ export const SlotOption = (args) => ({
47
+ components: { BaseTagAutocomplete },
48
+ setup() {
49
+ const value = ref(null);
50
+
51
+ const options = [
52
+ { label: 'Red', value: 'red' },
53
+ { label: 'Blue', value: 'blue' },
54
+ { label: 'Green', value: 'green' },
55
+ { label: 'Black', value: 'black' },
56
+ { label: 'Gray', value: 'gray' },
57
+ ];
58
+
59
+ return { value, options, args };
60
+ },
61
+ template: `
62
+ <BaseTagAutocomplete
63
+ v-bind="args"
64
+ v-model="value"
65
+ :options="options"
66
+ >
67
+ <template #option="{ option, active, selected }">
68
+ <div
69
+ class="rounded px-2 font-semibold py-1 text-sm"
70
+ :class="{
71
+ 'hover:bg-slate-100': !active && !selected,
72
+ 'bg-slate-200 hover:bg-slate-300': active && !selected,
73
+ 'bg-blue-500 text-white hover:bg-blue-600': !active && selected,
74
+ 'bg-blue-600 text-white hover:bg-blue-700': active && selected,
75
+ }"
76
+ :style="{ color: selected ? '' : option.value }"
77
+ >
78
+ {{ option.label }}
79
+ </div>
80
+ </template>
81
+ </BaseTagAutocomplete>
82
+ `,
83
+ });
84
+
85
+ export const SlotFooter = (args) => {
86
+ return {
87
+ components: { BaseTagAutocomplete },
88
+ setup() {
89
+ const value = ref(null);
90
+ function onClick() {
91
+ alert(1);
92
+ }
93
+ return { args, value, onClick };
94
+ },
95
+ template: `
96
+ <BaseTagAutocomplete
97
+ v-model="value"
98
+ v-bind="args"
99
+ >
100
+ <template #footer>
101
+ <div class="text-center p-2 border-t">
102
+ <button @click=onClick class="btn btn-sm w-full btn-slate-200-outline">This is the footer 💯</button>
103
+ </div>
104
+ </template>
105
+ </BaseTagAutocomplete>
106
+ `,
107
+ };
108
+ };
109
+
110
+ export const SlotEmpty = (args) => {
111
+ return {
112
+ components: { BaseTagAutocomplete },
113
+ setup() {
114
+ const value = ref(null);
115
+ return { args, value };
116
+ },
117
+ template: `
118
+ <BaseTagAutocomplete
119
+ v-model="value"
120
+ v-bind="args"
121
+ :options="[]"
122
+ >
123
+ <template #empty>
124
+ <div class="text-center p-6 py-10 flex items-center justify-center">🤓🤓🤓</div>
125
+ </template>
126
+ </BaseTagAutocomplete>
127
+ `,
128
+ };
129
+ };
@@ -9,12 +9,19 @@
9
9
  >
10
10
  <div
11
11
  class="flex items-stretch rounded border"
12
- :class="selectionClass(selection)"
12
+ :class="[
13
+ disabled ? 'cursor-not-allowed opacity-60' : '',
14
+ selectionClass(selection),
15
+ ]"
13
16
  >
14
- <div class="py-[5px] pl-3 pr-1 text-sm">
17
+ <div
18
+ class="py-[5px] pl-3 text-sm"
19
+ :class="[disabled ? 'pr-3' : 'pr-1']"
20
+ >
15
21
  {{ selection.label }}
16
22
  </div>
17
23
  <button
24
+ v-if="!disabled"
18
25
  type="button"
19
26
  class="flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent pl-1 pr-3 text-xs outline-none"
20
27
  @click="dontLooseFocus($event, () => removeOption(selection))"
@@ -27,6 +34,7 @@
27
34
  <div class="grow p-0.5">
28
35
  <input
29
36
  ref="input"
37
+ :disabled="disabled"
30
38
  :value="keywords"
31
39
  type="text"
32
40
  :placeholder="$t('sui.select_an_item')"
@@ -44,34 +52,73 @@
44
52
  <div class="relative">
45
53
  <div
46
54
  v-show="showDropdown"
47
- class="absolute top-1 z-[1] min-h-[100px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md"
55
+ class="absolute top-1 z-menu min-h-[110px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md"
48
56
  >
49
- <ul ref="dropdown" class="max-h-[214px] w-full overflow-y-auto p-1">
50
- <li
51
- v-for="option in normalizedOptions"
52
- :key="option.value"
53
- class="block"
54
- >
55
- <button
56
- class="block w-full cursor-pointer appearance-none rounded-sm border-none px-2 py-1 text-left text-sm focus:outline-none"
57
- :class="optionClass(option)"
58
- type="button"
59
- tabindex="-1"
60
- @click="dontLooseFocus($event, () => addOption(option))"
61
- @mousedown="dontLooseFocus"
57
+ <div
58
+ ref="dropdown"
59
+ class="max-h-[214px] min-h-[75px] w-full overflow-y-auto"
60
+ >
61
+ <slot v-if="filteredNormalizedOptions.length == 0" name="empty">
62
+ <div
63
+ class="flex items-center justify-center px-5 py-10 text-center text-slate-600"
62
64
  >
63
- {{ option.label }}
64
- </button>
65
- </li>
66
- </ul>
65
+ {{ $t('sui.nothing_found') }}
66
+ </div>
67
+ </slot>
68
+
69
+ <ul v-else class="p-1">
70
+ <li
71
+ v-for="option in filteredNormalizedOptions"
72
+ :key="option.value"
73
+ class="block"
74
+ >
75
+ <button
76
+ class="block w-full cursor-pointer appearance-none border-none text-left focus:outline-none"
77
+ type="button"
78
+ tabindex="-1"
79
+ @click="onSelect(option)"
80
+ @mousedown.prevent="dontLooseFocus"
81
+ >
82
+ <slot
83
+ name="option"
84
+ :option="option.option"
85
+ :selected="optionValues.includes(option.value)"
86
+ :active="optionActive && optionActive.value == option.value"
87
+ >
88
+ <div
89
+ class="rounded px-2 py-1 text-sm"
90
+ :class="optionClass(option)"
91
+ >
92
+ {{ option.label }}
93
+ </div>
94
+ </slot>
95
+ </button>
96
+ </li>
97
+ </ul>
98
+ </div>
67
99
 
68
- <slot v-if="normalizedOptions.length == 0" name="empty">
69
- <div class="p-5 text-center text-slate-600">
70
- {{ $t('sui.nothing_found') }}
100
+ <div ref="footer">
101
+ <div v-if="$slots.footer" class="bg-white">
102
+ <slot :options="filteredNormalizedOptions" name="footer" />
71
103
  </div>
72
- </slot>
104
+ </div>
73
105
 
74
- <BaseLoadingCover :model-value="loading" duration="duration-50" />
106
+ <div
107
+ v-if="loading"
108
+ class="absolute inset-0 h-full w-full space-y-1 bg-white p-2"
109
+ >
110
+ <div class="space-y-1">
111
+ <BaseSkeleton class="h-7 w-full" delay="0ms"></BaseSkeleton>
112
+ <BaseSkeleton
113
+ class="h-7 w-full opacity-70"
114
+ delay="50ms"
115
+ ></BaseSkeleton>
116
+ <BaseSkeleton
117
+ class="h-7 w-full opacity-30"
118
+ delay="100ms"
119
+ ></BaseSkeleton>
120
+ </div>
121
+ </div>
75
122
  </div>
76
123
  </div>
77
124
  </div>
@@ -81,9 +128,9 @@
81
128
  import { cloneDeep, get } from 'lodash';
82
129
  import { PropType, Ref, ComputedRef } from 'vue';
83
130
  import { NormalizedOption, Option, OptionValue } from '@/types/types';
84
- import { useInfiniteScroll } from '@vueuse/core';
85
- import BaseLoadingCover from './BaseLoadingCover.vue';
131
+ import { useInfiniteScroll, useMutationObserver } from '@vueuse/core';
86
132
  import { useNotificationsStore } from '@/stores/notifications';
133
+ import BaseSkeleton from './BaseSkeleton.vue';
87
134
 
88
135
  const props = defineProps({
89
136
  modelValue: {
@@ -122,13 +169,13 @@ const props = defineProps({
122
169
  default: false,
123
170
  type: Boolean,
124
171
  },
125
- min: {
172
+ max: {
126
173
  default: undefined,
127
174
  type: Number,
128
175
  },
129
- max: {
176
+ filter: {
130
177
  default: undefined,
131
- type: Number,
178
+ type: Function as PropType<(option: NormalizedOption) => boolean>,
132
179
  },
133
180
  });
134
181
 
@@ -156,14 +203,14 @@ onMounted(() => {
156
203
  () => {
157
204
  emit('scrollBottom');
158
205
  },
159
- { distance: 10 }
206
+ { distance: 60 }
160
207
  );
161
208
  });
162
209
 
163
210
  const optionActive = computed(() => {
164
211
  return (
165
- normalizedOptions.value[
166
- Math.min(selectionIndex.value, normalizedOptions.value.length - 1)
212
+ filteredNormalizedOptions.value[
213
+ Math.min(selectionIndex.value, filteredNormalizedOptions.value.length - 1)
167
214
  ] ?? null
168
215
  );
169
216
  });
@@ -182,13 +229,25 @@ const normalizedModelValue = computed(() => {
182
229
  }) as ComputedRef<NormalizedOption[]>;
183
230
 
184
231
  const normalizedOptions = computed(() => {
185
- return props.options
186
- .map((option) => {
187
- return {
188
- label: option[props.labelKey] as string,
189
- value: option[props.valueKey] as string | number,
190
- option: option,
191
- } as NormalizedOption;
232
+ return props.options.map((option) => {
233
+ return {
234
+ label: option[props.labelKey] as string,
235
+ value: option[props.valueKey] as string | number,
236
+ option: option,
237
+ } as NormalizedOption;
238
+ });
239
+ });
240
+
241
+ const filteredNormalizedOptions = computed((): NormalizedOption[] => {
242
+ return normalizedOptions.value
243
+ .filter((option) => {
244
+ if (props.filter !== undefined) {
245
+ return props.filter(option);
246
+ }
247
+ if (!option.label) {
248
+ return false;
249
+ }
250
+ return option.label.toLowerCase().includes(keywords.value.toLowerCase());
192
251
  })
193
252
  .filter((option) => {
194
253
  return !hasSelectedOption(option.value);
@@ -205,6 +264,15 @@ const hasSelectedOption = (value: OptionValue): boolean => {
205
264
  return optionValues.value.includes(value);
206
265
  };
207
266
 
267
+ function preventUnfocus(elements: HTMLElement[]) {
268
+ elements.forEach((e) => {
269
+ e.removeEventListener('mousedown', dontLooseFocus);
270
+ });
271
+ elements.forEach((e) => {
272
+ e.addEventListener('mousedown', dontLooseFocus);
273
+ });
274
+ }
275
+
208
276
  const dontLooseFocus = (event: Event, next: null | (() => void) = null) => {
209
277
  event.preventDefault();
210
278
  inputElement.value?.focus();
@@ -274,13 +342,24 @@ const onTextKeydown = (event: KeyboardEvent) => {
274
342
  };
275
343
 
276
344
  const optionClass = (option: NormalizedOption) => {
277
- if (normalizedModelValue.value.map((o) => o.value).includes(option.value)) {
278
- return 'bg-blue-600 text-white';
345
+ const active = optionActive.value && optionActive.value.value == option.value;
346
+ const selected =
347
+ normalizedModelValue.value &&
348
+ normalizedModelValue.value.map((o) => o.value).includes(option.value);
349
+
350
+ if (selected) {
351
+ if (active) {
352
+ return 'bg-blue-600 hover:bg-blue-700 text-white';
353
+ }
354
+
355
+ return 'bg-blue-500 hover:bg-blue-600 text-white';
279
356
  }
280
- if (optionActive.value && optionActive.value.value == option.value) {
281
- return 'bg-slate-200';
357
+
358
+ if (active) {
359
+ return 'bg-slate-200 hover:bg-slate-300';
282
360
  }
283
- return 'bg-white hover:bg-slate-200';
361
+
362
+ return 'bg-white hover:bg-slate-100';
284
363
  };
285
364
 
286
365
  const selectionClass = (selection: NormalizedOption): string => {
@@ -293,24 +372,18 @@ const selectionClass = (selection: NormalizedOption): string => {
293
372
  return 'bg-slate-200 border-slate-300';
294
373
  };
295
374
 
296
- const setKeywords = (input: string) => {
297
- keywords.value = input;
298
- emit('typing', input);
299
- };
300
-
301
- const toggleOption = (option: NormalizedOption) => {
302
- if (hasSelectedOption(option.value)) {
303
- removeOption(option);
304
- } else {
305
- addOption(option);
306
- }
375
+ const onSelect = (normalizedModelValue: NormalizedOption) => {
376
+ addOption(normalizedModelValue);
377
+ inputElement.value?.blur();
307
378
  };
308
379
 
309
380
  const addOption = (option: NormalizedOption) => {
310
381
  if (props.max && normalizedModelValue.value.length >= props.max) {
311
382
  notifications.push({
312
383
  title: i18n.t('sui.whoops'),
313
- text: i18n.t('sui.you_cannot_select_more_than_x_items', { x: props.max }),
384
+ text: i18n.t('sui.you_cannot_select_more_than_x_items', {
385
+ count: props.max,
386
+ }),
314
387
  color: 'warning',
315
388
  });
316
389
  return;
@@ -383,4 +456,29 @@ const validateSelectionIndex = () => {
383
456
  );
384
457
  });
385
458
  };
459
+
460
+ const setKeywords = (input: string) => {
461
+ keywords.value = input;
462
+ emit('typing', input);
463
+ };
464
+
465
+ const footer = ref(null) as Ref<HTMLDivElement | null>;
466
+
467
+ function preventUnfocusOnFooter() {
468
+ const elements = (footer.value?.querySelectorAll('button, a') ??
469
+ []) as HTMLElement[];
470
+ preventUnfocus(elements);
471
+ }
472
+
473
+ onMounted(() => {
474
+ preventUnfocusOnFooter();
475
+ });
476
+
477
+ useMutationObserver(
478
+ footer,
479
+ () => {
480
+ preventUnfocusOnFooter();
481
+ },
482
+ { attributes: false, childList: true }
483
+ );
386
484
  </script>
@@ -0,0 +1,130 @@
1
+ import BaseTagAutocompleteFetch from './BaseTagAutocompleteFetch.vue';
2
+ import BaseApp from './BaseApp.vue';
3
+
4
+ export default {
5
+ title: 'Form/BaseTagAutocompleteFetch',
6
+ component: BaseTagAutocompleteFetch,
7
+ argTypes: {},
8
+ args: {
9
+ url: 'https://effettandem.com/api/content/articles',
10
+ labelKey: 'title',
11
+ valueKey: 'id',
12
+ disabled: false,
13
+ },
14
+ decorators: [() => ({ template: '<div class="mb-36"><story/></div>' })],
15
+ };
16
+
17
+ const Template = (args) => {
18
+ return {
19
+ components: { BaseTagAutocompleteFetch, BaseApp },
20
+ setup() {
21
+ const value = ref([]);
22
+ return { args, value };
23
+ },
24
+ template: `
25
+ <BaseTagAutocompleteFetch
26
+ v-model="value"
27
+ v-bind="args"
28
+ ></BaseTagAutocompleteFetch>
29
+ <p class="mt-5 text-sm">Value: <span class="bg-slate-200 font-mono px-1 py-px rounded">{{ value ?? 'NULL' }}</span></p>
30
+ <BaseApp />
31
+ `,
32
+ };
33
+ };
34
+
35
+ export const Demo = Template.bind({});
36
+ Demo.args = {};
37
+
38
+ export const Disabled = Template.bind({});
39
+ Disabled.args = {
40
+ modelValue: [{ label: 'Dark Maul', value: '1' }],
41
+ labelKey: 'label',
42
+ valueKey: 'value',
43
+ disabled: true,
44
+ };
45
+
46
+ export const Maximum = Template.bind({});
47
+ Maximum.args = {
48
+ max: 3,
49
+ };
50
+
51
+ export const SlotOption = (args) => {
52
+ return {
53
+ components: { BaseTagAutocompleteFetch },
54
+ setup() {
55
+ const value = ref([]);
56
+ return { args, value };
57
+ },
58
+ template: `
59
+ <div class="mb-20">
60
+ <BaseTagAutocompleteFetch
61
+ v-model="value"
62
+ v-bind="args"
63
+ >
64
+ <template #option="{ option, active, selected }">
65
+ <div
66
+ class="rounded px-2 py-1"
67
+ :class="{
68
+ 'hover:bg-slate-100': !active && !selected,
69
+ 'bg-slate-200 hover:bg-slate-300': active && !selected,
70
+ 'bg-blue-500 text-white hover:bg-blue-600': !active && selected,
71
+ 'bg-blue-600 text-white hover:bg-blue-700': active && selected,
72
+ }"
73
+ >
74
+ <p class="text-sm font-medium">{{ option.title }}</p>
75
+ <p class="opacity-60 text-xs">{{ option.owner?.name }}</p>
76
+ </div>
77
+ </template>
78
+ </BaseTagAutocompleteFetch>
79
+ </div>
80
+ `,
81
+ };
82
+ };
83
+
84
+ export const SlotFooter = (args) => {
85
+ return {
86
+ components: { BaseTagAutocompleteFetch },
87
+ setup() {
88
+ const value = ref([]);
89
+ function onClick() {
90
+ alert(1);
91
+ }
92
+ return { args, value, onClick };
93
+ },
94
+ template: `
95
+ <BaseTagAutocompleteFetch
96
+ v-model="value"
97
+ v-bind="args"
98
+ >
99
+ <template #footer>
100
+ <div class="text-center p-2 border-t">
101
+ <button @click=onClick class="btn btn-sm w-full btn-slate-200-outline">This is the footer 💯</button>
102
+ </div>
103
+ </template>
104
+ </BaseTagAutocompleteFetch>
105
+ `,
106
+ };
107
+ };
108
+
109
+ export const SlotEmpty = (args) => {
110
+ return {
111
+ components: { BaseTagAutocompleteFetch },
112
+ setup() {
113
+ const value = ref([]);
114
+ return { args, value };
115
+ },
116
+ template: `
117
+ <BaseTagAutocompleteFetch
118
+ v-model="value"
119
+ v-bind="args"
120
+ >
121
+ <template #empty="props">
122
+ <div>
123
+ <div v-if="props.firstSearch" class="text-center py-10 p-6">🤓🤓🤓</div>
124
+ <div v-else class="text-center p-6">Start your search... 🔎</div>
125
+ </div>
126
+ </template>
127
+ </BaseTagAutocompleteFetch>
128
+ `,
129
+ };
130
+ };