@saooti/octopus-sdk 41.11.0 → 41.12.0-beta

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.
@@ -16,8 +16,22 @@
16
16
  class="octopus-multiselect-field"
17
17
  :class="{ disabled: isDisabled, open: isOpen, noBorder }"
18
18
  @click="openDropdown"
19
+ @mouseenter="isHovered = true"
20
+ @mouseleave="isHovered = false"
19
21
  >
22
+ <div
23
+ v-show="hasSelected && !isOpen"
24
+ ref="selectionRef"
25
+ class="octopus-multiselect-selection"
26
+ >
27
+ <span class="octopus-multiselect-selection-text">{{ selectionLabels }}</span>
28
+ <span
29
+ v-if="overflowCount > 0"
30
+ class="octopus-multiselect-selection-count"
31
+ >(+{{ overflowCount }})</span>
32
+ </div>
20
33
  <input
34
+ v-show="!hasSelected || isOpen"
21
35
  :id="computedId"
22
36
  ref="inputRef"
23
37
  v-model="searchQuery"
@@ -37,6 +51,13 @@
37
51
  </button>
38
52
  </div>
39
53
 
54
+ <div
55
+ v-if="expandOnHover && isHovered && !isOpen && overflowCount > 0"
56
+ class="octopus-multiselect-hover-tooltip"
57
+ >
58
+ {{ allLabelsText }}
59
+ </div>
60
+
40
61
  <div v-if="isOpen" class="octopus-multiselect-dropdown">
41
62
  <ClassicCheckbox
42
63
  :text-init="allSelected"
@@ -63,11 +84,11 @@
63
84
  </template>
64
85
 
65
86
  <script setup lang="ts" generic="T">
66
- import { computed, getCurrentInstance, nextTick, ref, shallowRef, watch } from 'vue';
67
- import { onClickOutside } from '@vueuse/core';
87
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
68
88
  import { useI18n } from 'vue-i18n';
69
89
  import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
70
90
  import ClassicCheckbox from './ClassicCheckbox.vue';
91
+ import { useOctopusDropdown } from '../composable/form/useOctopusDropdown';
71
92
 
72
93
  const props = defineProps<{
73
94
  /** Optional label displayed above the field. */
@@ -76,6 +97,8 @@ const props = defineProps<{
76
97
  selected?: T[];
77
98
  /** Full list of options to display or filter. */
78
99
  options: T[];
100
+ /** Key of each option object to use as the ID */
101
+ optionKey?: keyof T;
79
102
  /** Key of each option object to use as the display label. */
80
103
  optionLabel: keyof T & string;
81
104
  /** Disables the field and all checkboxes when true. */
@@ -84,47 +107,39 @@ const props = defineProps<{
84
107
  placeholder?: string;
85
108
  /** Label for the "select all" checkbox. Defaults to the translated "All" string. */
86
109
  selectAllText?: string;
87
- /** If provided, called on every input change; its return value replaces the displayed options. */
88
- onSearch?: (query: string) => T[] | Promise<T[]>;
89
110
  /** Disable the border around the input */
90
111
  noBorder?: boolean;
112
+ /** When true, hovering the closed field with overflow shows a tooltip listing all selected items. */
113
+ expandOnHover?: boolean;
91
114
  }>();
92
115
 
93
116
  const emit = defineEmits<{
94
117
  /** Emitted when the selection changes. */
95
118
  (e: 'update:selected', value: T[]): void;
119
+ /** Emitted on every input change. When listened to, the parent is responsible for
120
+ * updating `options`; otherwise the component filters `options` client-side. */
121
+ (e: 'search', query: string): void;
96
122
  }>();
97
123
 
98
124
  const { t } = useI18n();
99
125
 
100
- const searchQuery = ref('');
101
- const isOpen = ref(false);
102
- const internalOptions = shallowRef<T[]>([]);
103
- const containerRef = ref<HTMLElement | null>(null);
104
- const inputRef = ref<HTMLInputElement | null>(null);
105
-
106
- const computedId = computed(() => 'multiselect-' + getCurrentInstance()?.uid);
107
-
108
- watch(
109
- () => props.options,
110
- (val) => {
111
- internalOptions.value = val;
112
- },
113
- { immediate: true }
114
- );
115
-
116
- const displayedOptions = computed((): Array<T> => {
117
- if (props.onSearch) {
118
- return internalOptions.value;
119
- }
120
- const query = searchQuery.value.toLowerCase();
121
- if (!query) {
122
- return props.options;
123
- }
124
- return props.options.filter((option) =>
125
- getLabel(option).toLowerCase().includes(query)
126
- );
127
- });
126
+ const {
127
+ searchQuery,
128
+ isOpen,
129
+ isHovered,
130
+ containerRef,
131
+ inputRef,
132
+ computedId,
133
+ displayedOptions,
134
+ inputPlaceholder,
135
+ getLabel,
136
+ openDropdown,
137
+ toggleDropdown,
138
+ handleInput,
139
+ } = useOctopusDropdown(props, (query) => emit('search', query), 'multiselect');
140
+
141
+ const selectionRef = ref<HTMLElement | null>(null);
142
+ const visibleCount = ref(2);
128
143
 
129
144
  const allSelected = computed(() => {
130
145
  if (displayedOptions.value.length === 0) {
@@ -133,30 +148,36 @@ const allSelected = computed(() => {
133
148
  return displayedOptions.value.every((option) => isSelected(option));
134
149
  });
135
150
 
136
- const inputPlaceholder = computed(() => {
137
- const selected = props.selected ?? [];
138
- if (selected.length === 0) {
139
- return props.placeholder ?? t('Search');
140
- }
141
- const labels = selected.map(getLabel);
142
- if (labels.length <= 2) {
143
- return labels.join(', ');
144
- }
145
- return `${labels.slice(0, 2).join(', ')} (+${labels.length - 2})`;
146
- });
151
+ const hasSelected = computed(() => (props.selected?.length ?? 0) > 0);
147
152
 
148
- function getLabel(option: T): string {
149
- return option[props.optionLabel] as string;
150
- }
153
+ const selectionLabels = computed(() =>
154
+ (props.selected ?? []).slice(0, visibleCount.value).map(getLabel).join(', ')
155
+ );
156
+
157
+ const overflowCount = computed(() =>
158
+ Math.max(0, (props.selected?.length ?? 0) - visibleCount.value)
159
+ );
160
+
161
+ const allLabelsText = computed(() =>
162
+ (props.selected ?? []).map(getLabel).join(', ')
163
+ );
151
164
 
152
165
  function isSelected(option: T): boolean {
153
- return props.selected?.includes(option) ?? false;
166
+ if (props.optionKey) {
167
+ return props.selected?.find(s => s[props.optionKey] === option[props.optionKey]) !== undefined;
168
+ } else {
169
+ return props.selected?.includes(option) ?? false;
170
+ }
154
171
  }
155
172
 
156
173
  function toggleOption(option: T): void {
157
174
  const current = props.selected ?? [];
158
175
  if (isSelected(option)) {
159
- emit('update:selected', current.filter((item) => item !== option));
176
+ const key = props.optionKey;
177
+ emit('update:selected', key
178
+ ? current.filter((item) => item[key] !== option[key])
179
+ : current.filter((item) => item !== option)
180
+ );
160
181
  } else {
161
182
  emit('update:selected', [...current, option]);
162
183
  }
@@ -168,45 +189,80 @@ function toggleAll(val: boolean): void {
168
189
  const toAdd = displayedOptions.value.filter((option: T) => !isSelected(option));
169
190
  emit('update:selected', [...current, ...toAdd]);
170
191
  } else {
171
- emit('update:selected', current.filter((item: T) => !displayedOptions.value.includes(item)));
192
+ const key = props.optionKey;
193
+ emit('update:selected', current.filter((item: T) => key
194
+ ? !displayedOptions.value.some((opt) => opt[key] === item[key])
195
+ : !displayedOptions.value.includes(item)
196
+ ));
172
197
  }
173
198
  }
174
199
 
175
- function openDropdown(): void {
176
- if (props.isDisabled) {
200
+ function updateVisibleCount(): void {
201
+ const container = selectionRef.value;
202
+ const selected = props.selected ?? [];
203
+ if (!container || selected.length < 2) {
204
+ visibleCount.value = selected.length;
177
205
  return;
178
206
  }
179
- isOpen.value = true;
180
- nextTick(() => {
181
- inputRef.value?.focus();
182
- });
183
- }
184
-
185
- function closeDropdown(): void {
186
- isOpen.value = false;
187
- searchQuery.value = '';
188
- }
189
207
 
190
- function toggleDropdown(): void {
191
- if (isOpen.value) {
192
- closeDropdown();
193
- } else {
194
- openDropdown();
208
+ const availableWidth = container.offsetWidth;
209
+ if (availableWidth === 0) {
210
+ return;
195
211
  }
196
- }
197
212
 
198
- async function handleInput(): Promise<void> {
199
- if (!props.onSearch) {
213
+ const labels = selected.map(getLabel);
214
+ const measurer = document.createElement('span');
215
+ measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;pointer-events:none;';
216
+ container.appendChild(measurer);
217
+
218
+ measurer.textContent = labels.join(', ');
219
+ if (measurer.offsetWidth <= availableWidth) {
220
+ visibleCount.value = labels.length;
221
+ container.removeChild(measurer);
200
222
  return;
201
223
  }
202
- const result = await props.onSearch(searchQuery.value);
203
- internalOptions.value = result;
224
+
225
+ measurer.textContent = `(+${labels.length})`;
226
+ const badgeWidth = measurer.offsetWidth + 4;
227
+ const textAvailable = availableWidth - badgeWidth;
228
+
229
+ let count = 0;
230
+ for (let i = 0; i < labels.length; i++) {
231
+ measurer.textContent = labels.slice(0, i + 1).join(', ');
232
+ if (measurer.offsetWidth > textAvailable) {
233
+ break;
234
+ }
235
+ count = i + 1;
236
+ }
237
+
238
+ container.removeChild(measurer);
239
+ visibleCount.value = Math.max(1, count);
204
240
  }
205
241
 
206
- onClickOutside(containerRef, closeDropdown);
242
+ let resizeObserver: ResizeObserver | null = null;
243
+
244
+ onMounted(() => {
245
+ if (selectionRef.value) {
246
+ resizeObserver = new ResizeObserver(updateVisibleCount);
247
+ resizeObserver.observe(selectionRef.value);
248
+ }
249
+ updateVisibleCount();
250
+ });
251
+
252
+ onUnmounted(() => {
253
+ resizeObserver?.disconnect();
254
+ });
255
+
256
+ watch(() => props.selected, updateVisibleCount);
257
+
258
+ watch(isOpen, (val) => {
259
+ if (!val) {
260
+ nextTick(updateVisibleCount);
261
+ }
262
+ });
207
263
  </script>
208
264
 
209
- <style lang="scss">
265
+ <style scoped lang="scss">
210
266
  .octopus-multiselect {
211
267
  position: relative;
212
268
 
@@ -232,17 +288,57 @@ onClickOutside(containerRef, closeDropdown);
232
288
  }
233
289
  }
234
290
 
291
+ .octopus-multiselect-selection {
292
+ display: flex;
293
+ align-items: center;
294
+ flex: 1;
295
+ min-width: 0;
296
+ padding: 0.4rem 0.5rem;
297
+ height: 2rem;
298
+ gap: 0.25rem;
299
+ }
300
+
301
+ .octopus-multiselect-selection-text {
302
+ flex: 1;
303
+ min-width: 0;
304
+ overflow: hidden;
305
+ text-overflow: ellipsis;
306
+ white-space: nowrap;
307
+ }
308
+
309
+ .octopus-multiselect-selection-count {
310
+ flex-shrink: 0;
311
+ white-space: nowrap;
312
+ color: var(--octopus-primary);
313
+ }
314
+
235
315
  .octopus-multiselect-input {
236
316
  flex: 1;
237
317
  border: none;
238
318
  background: transparent;
239
319
  padding: 0.4rem 0.5rem;
320
+ padding-right: 0;
240
321
  height: 2rem;
241
322
  outline: none;
242
323
  cursor: inherit;
243
324
  min-width: 0;
244
325
  }
245
326
 
327
+ .octopus-multiselect-hover-tooltip {
328
+ position: absolute;
329
+ top: calc(100% + 2px);
330
+ left: 0;
331
+ right: 0;
332
+ z-index: 101;
333
+ background: white;
334
+ border: 1px solid var(--octopus-border-default);
335
+ border-radius: var(--octopus-border-radius);
336
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
337
+ padding: 0.5rem;
338
+ word-break: break-word;
339
+ pointer-events: none;
340
+ }
341
+
246
342
  .octopus-multiselect-chevron {
247
343
  padding: 0.25rem 0.5rem;
248
344
  display: flex;
@@ -0,0 +1,237 @@
1
+ <template>
2
+ <div
3
+ ref="containerRef"
4
+ class="octopus-select"
5
+ :class="{ 'form-margin': label }"
6
+ >
7
+ <label
8
+ v-if="label"
9
+ :for="computedId"
10
+ class="form-label"
11
+ >
12
+ {{ label }}
13
+ </label>
14
+
15
+ <div
16
+ class="octopus-select-field"
17
+ :class="{ disabled: isDisabled, open: isOpen, noBorder }"
18
+ @click="openDropdown"
19
+ >
20
+ <span
21
+ v-show="selectedLabel && !isOpen"
22
+ class="octopus-select-value"
23
+ >{{ selectedLabel }}</span>
24
+ <input
25
+ v-show="!selectedLabel || isOpen"
26
+ :id="computedId"
27
+ ref="inputRef"
28
+ v-model="searchQuery"
29
+ type="text"
30
+ class="octopus-select-input"
31
+ :placeholder="inputPlaceholder"
32
+ :disabled="isDisabled"
33
+ @focus="openDropdown"
34
+ @input="handleInput"
35
+ >
36
+ <button
37
+ class="btn-transparent octopus-select-chevron"
38
+ :disabled="isDisabled"
39
+ @click.stop="toggleDropdown"
40
+ >
41
+ <ChevronDownIcon />
42
+ </button>
43
+ </div>
44
+
45
+ <div v-if="isOpen" class="octopus-select-dropdown">
46
+ <div class="octopus-select-options">
47
+ <button
48
+ v-for="(option, index) in displayedOptions"
49
+ :key="index"
50
+ class="octopus-select-option"
51
+ :class="{ selected: isSelected(option) }"
52
+ @click="selectOption(option)"
53
+ >
54
+ {{ getLabel(option) }}
55
+ </button>
56
+ <span v-if="displayedOptions.length === 0" class="text-indic px-2">
57
+ {{ t('No elements found. Consider changing the search query.') }}
58
+ </span>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup lang="ts" generic="T">
65
+ import { computed, toRaw } from 'vue';
66
+ import { useI18n } from 'vue-i18n';
67
+ import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
68
+ import { useOctopusDropdown } from '../composable/form/useOctopusDropdown';
69
+
70
+ const props = withDefaults(defineProps<{
71
+ /** Optional label displayed above the field. */
72
+ label?: string;
73
+ /** Currently selected item. Bind with `v-model:value`. */
74
+ value?: T;
75
+ /** Full list of options to display or filter. */
76
+ options: T[];
77
+ /** Key of each option object to use as the ID. */
78
+ optionKey?: keyof T;
79
+ /** Key of each option object to use as the display label. */
80
+ optionLabel: keyof T & string;
81
+ /** Disables the field when true. */
82
+ isDisabled?: boolean;
83
+ /** Placeholder shown in the input when no item is selected. Defaults to the translated "Search" string. */
84
+ placeholder?: string;
85
+ /** Disable the border around the input. */
86
+ noBorder?: boolean;
87
+ /** When true (default), clicking the already-selected option clears the selection. */
88
+ allowDeselect?: boolean;
89
+ }>(), {
90
+ label: undefined,
91
+ value: undefined,
92
+ optionKey: undefined,
93
+ placeholder: undefined,
94
+ allowDeselect: true,
95
+ });
96
+
97
+ const emit = defineEmits<{
98
+ /** Emitted when the selection changes. */
99
+ (e: 'update:value', value: T | undefined): void;
100
+ /** Emitted on every input change. When listened to, the parent is responsible for
101
+ * updating `options`; otherwise the component filters `options` client-side. */
102
+ (e: 'search', query: string): void;
103
+ }>();
104
+
105
+ const { t } = useI18n();
106
+
107
+ const {
108
+ searchQuery,
109
+ isOpen,
110
+ containerRef,
111
+ inputRef,
112
+ computedId,
113
+ displayedOptions,
114
+ inputPlaceholder,
115
+ getLabel,
116
+ openDropdown,
117
+ closeDropdown,
118
+ toggleDropdown,
119
+ handleInput,
120
+ } = useOctopusDropdown(props, (query) => emit('search', query), 'select');
121
+
122
+ const selectedLabel = computed(() =>
123
+ props.value !== undefined ? getLabel(props.value) : undefined
124
+ );
125
+
126
+ function isSelected(option: T): boolean {
127
+ if (props.value === undefined) {
128
+ return false;
129
+ }
130
+ if (props.optionKey) {
131
+ return props.value[props.optionKey] === option[props.optionKey];
132
+ }
133
+ return toRaw(props.value as object) === toRaw(option as object);
134
+ }
135
+
136
+ function selectOption(option: T): void {
137
+ if (isSelected(option) && props.allowDeselect) {
138
+ emit('update:value', undefined);
139
+ } else {
140
+ emit('update:value', option);
141
+ }
142
+ closeDropdown();
143
+ }
144
+ </script>
145
+
146
+ <style scoped lang="scss">
147
+ .octopus-select {
148
+ position: relative;
149
+
150
+ .octopus-select-field {
151
+ display: flex;
152
+ align-items: center;
153
+ border: 1px solid var(--octopus-border-default);
154
+ border-radius: var(--octopus-border-radius);
155
+ background: white;
156
+ cursor: pointer;
157
+
158
+ &.open {
159
+ border-color: var(--octopus-primary);
160
+ }
161
+
162
+ &.disabled {
163
+ background: var(--octopus-secondary-lighter);
164
+ cursor: default;
165
+ }
166
+
167
+ &.noBorder {
168
+ border: none;
169
+ }
170
+ }
171
+
172
+ .octopus-select-value {
173
+ flex: 1;
174
+ min-width: 0;
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ padding: 0.4rem 0.5rem;
179
+ padding-right: 0;
180
+ height: 2rem;
181
+ }
182
+
183
+ .octopus-select-input {
184
+ flex: 1;
185
+ border: none;
186
+ background: transparent;
187
+ padding: 0.4rem 0.5rem;
188
+ height: 2rem;
189
+ outline: none;
190
+ cursor: inherit;
191
+ min-width: 0;
192
+ }
193
+
194
+ .octopus-select-chevron {
195
+ padding: 0.25rem 0.5rem;
196
+ display: flex;
197
+ align-items: center;
198
+ }
199
+
200
+ .octopus-select-dropdown {
201
+ position: absolute;
202
+ top: calc(100% + 2px);
203
+ left: 0;
204
+ right: 0;
205
+ z-index: 100;
206
+ background: white;
207
+ border: 1px solid var(--octopus-border-default);
208
+ border-radius: var(--octopus-border-radius);
209
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
210
+ padding: 0.25rem 0;
211
+ }
212
+
213
+ .octopus-select-options {
214
+ max-height: 14rem;
215
+ overflow-y: auto;
216
+ }
217
+
218
+ .octopus-select-option {
219
+ display: block;
220
+ width: 100%;
221
+ text-align: left;
222
+ padding: 0.25rem 0.5rem;
223
+ border: none;
224
+ background: transparent;
225
+ cursor: pointer;
226
+
227
+ &:hover {
228
+ background: var(--octopus-secondary-lighter);
229
+ }
230
+
231
+ &.selected {
232
+ font-weight: 600;
233
+ color: var(--octopus-primary);
234
+ }
235
+ }
236
+ }
237
+ </style>
@@ -48,7 +48,9 @@ const props = defineProps({
48
48
  popoverClass: { type: String, default: undefined },
49
49
  isTopLayer: { type: Boolean, default: false },
50
50
  /** @deprecated No longer needed. If set to true, max height of popover will not overflow from parent */
51
- constrainHeight: { type: Boolean, default: true }
51
+ constrainHeight: { type: Boolean, default: true },
52
+ /** Force z-index */
53
+ zIndex: { type: Number, default: undefined }
52
54
  })
53
55
 
54
56
  //Emits
@@ -74,7 +76,12 @@ const router = useRouter();
74
76
 
75
77
  //Computed
76
78
  const popoverId = computed(() => "popover" + props.target);
77
- const positionInlineStyle = computed(() => `left: ${posX.value}px; top: ${posY.value}px;max-height:${maxHeight.value}`);
79
+ const positionInlineStyle = computed(() => ({
80
+ left: `${posX.value}px`,
81
+ top: `${posY.value}px`,
82
+ 'max-height': maxHeight.value,
83
+ 'z-index': props.zIndex ?? 10
84
+ }));
78
85
  const displayPopover = computed(() => show.value && !props.disable);
79
86
  const isTopLayerPopover = computed(() => (props.isTopLayer || "octopus-modal"===props.relativeClass) && Object.hasOwn(HTMLElement.prototype, "popover"));
80
87
 
@@ -31,6 +31,7 @@
31
31
  </div>
32
32
  </div>
33
33
  <div v-show="!onlyHeader" class="octopus-modal-body">
34
+ <slot />
34
35
  <slot name="body" />
35
36
  </div>
36
37
  <div v-show="!onlyHeader" class="octopus-modal-footer">
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <section v-if="isInit" class="page-box">
3
+ <!-- TODO à intégrer dans frontoffice -->
3
4
  <router-link
4
5
  v-if="isRolePlaylists && !isPodcastmaker"
5
6
  to="/main/priv/edit/playlist"
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * Get a color from a string.
3
3
  * Using the same string, the color will always be the same.
4
+ *
5
+ * Uses FNV-1a hashing (XOR-then-multiply with the FNV prime 16777619) for strong
6
+ * bit avalanche: a single character difference propagates through all hash bits,
7
+ * so similar strings produce very different hues.
8
+ *
4
9
  * @param str The string used for the color
5
10
  * @param saturation Saturation of the color
6
11
  * @param lightness Lightness of the color
7
12
  * @returns A color
8
13
  */
9
14
  export function colorFromString(str: string, saturation = 80, lightness = 70) {
10
- let hash = 0;
11
- str.split('').forEach(char => {
12
- hash = char.charCodeAt(0) + ((hash << 5) - hash);
13
- });
14
- return `hsl(${(hash + 360) % 360}, ${saturation}%, ${lightness}%)`;
15
+ let hash = 2166136261;
16
+ for (const char of str) {
17
+ hash ^= char.charCodeAt(0);
18
+ hash = Math.imul(hash, 16777619) >>> 0;
19
+ }
20
+ const hue = Math.round((hash / 0xFFFFFFFF) * 360);
21
+ return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
15
22
  }