@flux-ui/components 3.1.2 → 3.1.3

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 (61) hide show
  1. package/dist/component/FluxAvatarGroup.vue.d.ts +17 -0
  2. package/dist/component/FluxContextMenu.vue.d.ts +26 -0
  3. package/dist/component/FluxDataTable.vue.d.ts +20 -10
  4. package/dist/component/FluxDescriptionItem.vue.d.ts +19 -0
  5. package/dist/component/FluxDescriptionList.vue.d.ts +17 -0
  6. package/dist/component/FluxFormCombobox.vue.d.ts +20 -0
  7. package/dist/component/FluxFormRating.vue.d.ts +21 -0
  8. package/dist/component/FluxFormTagsInput.vue.d.ts +27 -0
  9. package/dist/component/FluxFormTextArea.vue.d.ts +6 -1
  10. package/dist/component/FluxInlineEdit.vue.d.ts +41 -0
  11. package/dist/component/FluxMenu.vue.d.ts +1 -0
  12. package/dist/component/FluxMenuFlyout.vue.d.ts +22 -0
  13. package/dist/component/FluxTableCell.vue.d.ts +1 -0
  14. package/dist/component/FluxTour.vue.d.ts +35 -0
  15. package/dist/component/FluxTourItem.vue.d.ts +18 -0
  16. package/dist/component/FluxVirtualScroller.vue.d.ts +27 -0
  17. package/dist/component/index.d.ts +12 -0
  18. package/dist/component/primitive/AnchorPopup.vue.d.ts +7 -1
  19. package/dist/component/primitive/SelectBase.vue.d.ts +3 -0
  20. package/dist/composable/private/index.d.ts +1 -0
  21. package/dist/composable/private/useMenuFlyout.d.ts +42 -0
  22. package/dist/data/di.d.ts +35 -0
  23. package/dist/data/i18n.d.ts +7 -0
  24. package/dist/index.css +441 -1
  25. package/dist/index.js +2018 -407
  26. package/dist/index.js.map +1 -1
  27. package/package.json +7 -7
  28. package/src/component/FluxAvatarGroup.vue +52 -0
  29. package/src/component/FluxContextMenu.vue +134 -0
  30. package/src/component/FluxDataTable.vue +113 -32
  31. package/src/component/FluxDescriptionItem.vue +43 -0
  32. package/src/component/FluxDescriptionList.vue +37 -0
  33. package/src/component/FluxFormCombobox.vue +98 -0
  34. package/src/component/FluxFormRating.vue +172 -0
  35. package/src/component/FluxFormTagsInput.vue +249 -0
  36. package/src/component/FluxFormTextArea.vue +16 -1
  37. package/src/component/FluxInlineEdit.vue +176 -0
  38. package/src/component/FluxMenu.vue +13 -3
  39. package/src/component/FluxMenuFlyout.vue +118 -0
  40. package/src/component/FluxTableCell.vue +2 -0
  41. package/src/component/FluxTour.vue +332 -0
  42. package/src/component/FluxTourItem.vue +27 -0
  43. package/src/component/FluxVirtualScroller.vue +96 -0
  44. package/src/component/index.ts +12 -0
  45. package/src/component/primitive/AnchorPopup.vue +27 -0
  46. package/src/component/primitive/SelectBase.vue +37 -2
  47. package/src/composable/private/index.ts +1 -0
  48. package/src/composable/private/useMenuFlyout.ts +417 -0
  49. package/src/css/component/AvatarGroup.module.scss +22 -0
  50. package/src/css/component/ContextMenu.module.scss +17 -0
  51. package/src/css/component/DescriptionList.module.scss +98 -0
  52. package/src/css/component/Form.module.scss +51 -0
  53. package/src/css/component/FormRating.module.scss +47 -0
  54. package/src/css/component/InlineEdit.module.scss +45 -0
  55. package/src/css/component/Menu.module.scss +4 -1
  56. package/src/css/component/MenuFlyout.module.scss +38 -0
  57. package/src/css/component/Table.module.scss +16 -0
  58. package/src/css/component/Tour.module.scss +108 -0
  59. package/src/css/component/VirtualScroller.module.scss +17 -0
  60. package/src/data/di.ts +40 -0
  61. package/src/data/i18n.ts +7 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flux-ui/components",
3
3
  "description": "A set of opiniated UI components.",
4
- "version": "3.1.2",
4
+ "version": "3.1.3",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "funding": "https://github.com/sponsors/basmilius",
@@ -55,10 +55,10 @@
55
55
  "**/dist/index.css"
56
56
  ],
57
57
  "dependencies": {
58
- "@basmilius/common": "^3.41.0",
59
- "@basmilius/utils": "^3.41.0",
60
- "@flux-ui/internals": "3.1.2",
61
- "@flux-ui/types": "3.1.2",
58
+ "@basmilius/common": "^3.43.0",
59
+ "@basmilius/utils": "^3.43.0",
60
+ "@flux-ui/internals": "3.1.3",
61
+ "@flux-ui/types": "3.1.3",
62
62
  "@fortawesome/fontawesome-common-types": "^7.2.0",
63
63
  "clsx": "^2.1.1",
64
64
  "imask": "^7.6.1",
@@ -66,10 +66,10 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "luxon": "^3.7.2",
69
- "vue": "^3.6.0-beta.15"
69
+ "vue": "^3.6.0-beta.16"
70
70
  },
71
71
  "devDependencies": {
72
- "@basmilius/vite-preset": "^3.41.0",
72
+ "@basmilius/vite-preset": "^3.43.0",
73
73
  "@types/lodash-es": "^4.17.12",
74
74
  "@types/luxon": "^3.7.1",
75
75
  "@types/node": "^25.9.3",
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <div
3
+ :class="$style.avatarGroup"
4
+ :style="{
5
+ fontSize: `${size}px`,
6
+ '--overlap': overlap
7
+ }">
8
+ <component :is="renderVisible"/>
9
+
10
+ <span
11
+ v-if="overflowCount > 0"
12
+ :class="$style.avatarGroupItem">
13
+ <FluxAvatar
14
+ :alt="overflowLabel"
15
+ fallback="neutral"
16
+ :fallback-initials="`+${overflowCount}`"/>
17
+ </span>
18
+ </div>
19
+ </template>
20
+
21
+ <script
22
+ lang="ts"
23
+ setup>
24
+ import { flattenVNodeTree } from '@flux-ui/internals';
25
+ import { computed, h, type VNode } from 'vue';
26
+ import { useTranslate } from '~flux/components/composable/private';
27
+ import FluxAvatar from './FluxAvatar.vue';
28
+ import $style from '~flux/components/css/component/AvatarGroup.module.scss';
29
+
30
+ const {
31
+ max,
32
+ overlap = 0.3,
33
+ size = 32
34
+ } = defineProps<{
35
+ readonly max?: number;
36
+ readonly overlap?: number;
37
+ readonly size?: number;
38
+ }>();
39
+
40
+ const slots = defineSlots<{
41
+ default(): VNode[];
42
+ }>();
43
+
44
+ const translate = useTranslate();
45
+
46
+ const children = computed(() => flattenVNodeTree(slots.default?.() ?? []).filter(vnode => typeof vnode.type !== 'symbol'));
47
+ const visibleNodes = computed(() => max !== undefined && children.value.length > max ? children.value.slice(0, max) : children.value);
48
+ const overflowCount = computed(() => max !== undefined ? Math.max(0, children.value.length - max) : 0);
49
+ const overflowLabel = computed(() => translate('flux.andNMore', {n: overflowCount.value}));
50
+
51
+ const renderVisible = () => visibleNodes.value.map(vnode => h('span', {class: $style.avatarGroupItem}, [vnode]));
52
+ </script>
@@ -0,0 +1,134 @@
1
+ <template>
2
+ <div
3
+ :class="$style.contextMenu"
4
+ @contextmenu="onContextMenu">
5
+ <slot/>
6
+
7
+ <Teleport to="body">
8
+ <FluxFadeTransition>
9
+ <AnchorPopup
10
+ v-if="isOpen"
11
+ ref="popup"
12
+ :anchor="virtualAnchor"
13
+ :class="$style.contextMenuPopup"
14
+ clamp-to-viewport
15
+ :margin="2"
16
+ :position="position"
17
+ role="menu"
18
+ @click="close()">
19
+ <slot
20
+ name="menu"
21
+ v-bind="{close}"/>
22
+ </AnchorPopup>
23
+ </FluxFadeTransition>
24
+ </Teleport>
25
+ </div>
26
+ </template>
27
+
28
+ <script
29
+ lang="ts"
30
+ setup>
31
+ import { isSSR, useEventListener, useFocusTrap } from '@flux-ui/internals';
32
+ import { computed, type ComponentPublicInstance, reactive, ref, toRef, useTemplateRef, type VNode } from 'vue';
33
+ import { useDisabled } from '~flux/components/composable';
34
+ import { useMenuFlyoutProvider } from '~flux/components/composable/private';
35
+ import { FluxFadeTransition } from '~flux/components/transition';
36
+ import { AnchorPopup } from '~flux/components/component/primitive';
37
+ import $style from '~flux/components/css/component/ContextMenu.module.scss';
38
+
39
+ const {
40
+ debugCone = false,
41
+ disabled: componentDisabled,
42
+ position = 'bottom-left'
43
+ } = defineProps<{
44
+ readonly debugCone?: boolean;
45
+ readonly disabled?: boolean;
46
+ readonly position?:
47
+ | 'top' | 'top-left' | 'top-right'
48
+ | 'left' | 'left-top' | 'left-bottom'
49
+ | 'right' | 'right-top' | 'right-bottom'
50
+ | 'bottom' | 'bottom-left' | 'bottom-right';
51
+ }>();
52
+
53
+ const emit = defineEmits<{
54
+ open: [MouseEvent];
55
+ close: [];
56
+ }>();
57
+
58
+ defineSlots<{
59
+ default(): VNode[];
60
+ menu(props: {close(): void}): VNode[];
61
+ }>();
62
+
63
+ const disabled = useDisabled(toRef(() => componentDisabled));
64
+ const popupRef = useTemplateRef<ComponentPublicInstance>('popup');
65
+
66
+ const isOpen = ref(false);
67
+ const cursor = reactive({x: 0, y: 0});
68
+ const virtualAnchor = {
69
+ $el: {
70
+ getBoundingClientRect: () => new DOMRect(cursor.x, cursor.y, 0, 0)
71
+ }
72
+ } as unknown as ComponentPublicInstance;
73
+
74
+ const menuFlyout = useMenuFlyoutProvider({
75
+ debugCone: toRef(() => debugCone),
76
+ onCloseAll: () => close()
77
+ });
78
+
79
+ useFocusTrap(popupRef, {
80
+ disable: computed(() => menuFlyout.keyboardStack.value.length > 0)
81
+ });
82
+
83
+ function onContextMenu(evt: MouseEvent): void {
84
+ if (disabled.value) {
85
+ return;
86
+ }
87
+
88
+ evt.preventDefault();
89
+
90
+ cursor.x = evt.clientX;
91
+ cursor.y = evt.clientY;
92
+ isOpen.value = true;
93
+
94
+ emit('open', evt);
95
+ }
96
+
97
+ function close(): void {
98
+ if (!isOpen.value) {
99
+ return;
100
+ }
101
+
102
+ isOpen.value = false;
103
+ emit('close');
104
+ }
105
+
106
+ if (!isSSR) {
107
+ useEventListener(ref(window), 'pointerdown', (evt: PointerEvent) => {
108
+ if (!isOpen.value) {
109
+ return;
110
+ }
111
+
112
+ const target = evt.target as Node | null;
113
+ const root = (popupRef.value?.$el ?? null) as HTMLElement | null;
114
+
115
+ if ((root && target && root.contains(target)) || menuFlyout.isInsidePopups(target)) {
116
+ return;
117
+ }
118
+
119
+ close();
120
+ }, {capture: true});
121
+
122
+ useEventListener(ref(window), 'keydown', (evt: KeyboardEvent) => {
123
+ if (isOpen.value && evt.key === 'Escape') {
124
+ close();
125
+ }
126
+ });
127
+
128
+ useEventListener(ref(window), 'scroll', () => {
129
+ if (isOpen.value) {
130
+ close();
131
+ }
132
+ }, {capture: true});
133
+ }
134
+ </script>
@@ -14,7 +14,7 @@
14
14
  </template>
15
15
 
16
16
  <template
17
- v-if="'header' in slots || selectionMode"
17
+ v-if="'header' in slots || selectionMode || hasExpandable"
18
18
  #header>
19
19
  <slot
20
20
  name="filter"
@@ -31,6 +31,11 @@
31
31
  @update:model-value="onSelectAll"/>
32
32
  </FluxTableHeader>
33
33
 
34
+ <FluxTableHeader
35
+ v-if="hasExpandable"
36
+ is-shrinking
37
+ :class="$style.tableCellExpand"/>
38
+
34
39
  <slot
35
40
  name="header"
36
41
  v-bind="{page, perPage, items: limitedItems, total}"/>
@@ -63,27 +68,54 @@
63
68
  </slot>
64
69
  </template>
65
70
 
66
- <FluxTableRow
71
+ <template
67
72
  v-for="(item, index) of limitedItems"
68
- :key="uniqueKey ? item[uniqueKey] : index"
69
- :class="selectionMode && !treeDisabled && $style.isSelectableRow"
70
- :is-selected="selectionMode ? isItemSelected(item) : false"
71
- @click="onRowClick(item, $event)">
72
- <FluxTableCell
73
- v-if="selectionMode"
74
- :class="$style.tableCellSelection">
75
- <FluxFormCheckbox
76
- :model-value="isItemSelected(item)"
77
- @update:model-value="onSelectRow(item)"/>
78
- </FluxTableCell>
79
-
80
- <template v-for="(_, name) of slots">
81
- <slot
82
- v-if="!IGNORED_SLOTS.includes(name as string)"
83
- v-bind="{index, item, items: limitedItems, page, perPage, total, isSelected: isItemSelected(item)}"
84
- :name="name"/>
85
- </template>
86
- </FluxTableRow>
73
+ :key="uniqueKey ? item[uniqueKey] : index">
74
+ <FluxTableRow
75
+ :class="selectionMode && !treeDisabled && $style.isSelectableRow"
76
+ :is-selected="selectionMode ? isItemSelected(item) : false"
77
+ @click="onRowClick(item, $event)">
78
+ <FluxTableCell
79
+ v-if="selectionMode"
80
+ :class="$style.tableCellSelection">
81
+ <FluxFormCheckbox
82
+ :model-value="isItemSelected(item)"
83
+ @update:model-value="onSelectRow(item)"/>
84
+ </FluxTableCell>
85
+
86
+ <FluxTableCell
87
+ v-if="hasExpandable"
88
+ :class="$style.tableCellExpand">
89
+ <FluxAction
90
+ :class="clsx($style.tableExpandToggle, isItemExpanded(item) && $style.isExpanded)"
91
+ icon="chevron-right"
92
+ :aria-expanded="isItemExpanded(item)"
93
+ :aria-label="isItemExpanded(item) ? translate('flux.collapseRow') : translate('flux.expandRow')"
94
+ @click="toggleExpand(item)"/>
95
+ </FluxTableCell>
96
+
97
+ <template v-for="(_, name) of slots">
98
+ <slot
99
+ v-if="!IGNORED_SLOTS.includes(name as string)"
100
+ v-bind="{index, item, items: limitedItems, page, perPage, total, isSelected: isItemSelected(item)}"
101
+ :name="name"/>
102
+ </template>
103
+ </FluxTableRow>
104
+
105
+ <FluxTableRow
106
+ v-if="hasExpandable && isItemExpanded(item)"
107
+ :class="$style.tableExpandRow">
108
+ <FluxTableCell :colspan="columnCount">
109
+ <template #content>
110
+ <div :class="$style.tableExpandContent">
111
+ <slot
112
+ name="expandable"
113
+ v-bind="{index, item, isExpanded: true, toggle: () => toggleExpand(item)}"/>
114
+ </div>
115
+ </template>
116
+ </FluxTableCell>
117
+ </FluxTableRow>
118
+ </template>
87
119
  </FluxTable>
88
120
  </template>
89
121
 
@@ -91,8 +123,11 @@
91
123
  lang="ts"
92
124
  setup
93
125
  generic="T extends Record<string, any>">
126
+ import { clsx } from 'clsx';
94
127
  import { computed, unref, useTemplateRef, type VNode, watch } from 'vue';
95
128
  import { useDisabledInjection } from '~flux/components/composable';
129
+ import { useTranslate } from '~flux/components/composable/private';
130
+ import FluxAction from './FluxAction.vue';
96
131
  import FluxFormCheckbox from './FluxFormCheckbox.vue';
97
132
  import FluxPaginationBar from './FluxPaginationBar.vue';
98
133
  import FluxTable from './FluxTable.vue';
@@ -104,7 +139,7 @@
104
139
  type SelectionId = string | number;
105
140
  type SelectionValue = SelectionId | null | SelectionId[];
106
141
 
107
- const IGNORED_SLOTS: string[] = ['filter', 'header', 'footer', 'colgroups', 'pagination'];
142
+ const IGNORED_SLOTS: string[] = ['filter', 'header', 'footer', 'colgroups', 'pagination', 'expandable'];
108
143
 
109
144
  const emit = defineEmits<{
110
145
  limit: [number];
@@ -112,8 +147,12 @@
112
147
  }>();
113
148
 
114
149
  const selected = defineModel<SelectionValue>('selected');
150
+ const expanded = defineModel<SelectionId[]>('expanded', {
151
+ default: () => []
152
+ });
115
153
 
116
154
  const {
155
+ expandMode = 'multiple',
117
156
  isBordered = true,
118
157
  isHoverable = false,
119
158
  isLoading = false,
@@ -124,6 +163,7 @@
124
163
  selectionMode,
125
164
  uniqueKey
126
165
  } = defineProps<{
166
+ readonly expandMode?: 'single' | 'multiple';
127
167
  readonly fillColumns?: number;
128
168
  readonly isBordered?: boolean;
129
169
  readonly isHoverable?: boolean;
@@ -140,16 +180,6 @@
140
180
  }>();
141
181
 
142
182
  const slots = defineSlots<{
143
- [key: string]: (props: {
144
- readonly index: number;
145
- readonly page: number;
146
- readonly perPage: number;
147
- readonly item: T;
148
- readonly items: T[];
149
- readonly total: number;
150
- readonly isSelected: boolean;
151
- }) => VNode;
152
-
153
183
  filter(props: {
154
184
  readonly page: number;
155
185
  readonly perPage: number;
@@ -179,13 +209,38 @@
179
209
  }): VNode;
180
210
 
181
211
  colgroups(): VNode;
212
+
213
+ expandable(props: {
214
+ readonly index: number;
215
+ readonly item: T;
216
+ readonly isExpanded: boolean;
217
+
218
+ toggle(): void;
219
+ }): VNode;
220
+ } & {
221
+ [key: string]: (props: {
222
+ readonly index: number;
223
+ readonly page: number;
224
+ readonly perPage: number;
225
+ readonly item: T;
226
+ readonly items: T[];
227
+ readonly total: number;
228
+ readonly isSelected: boolean;
229
+ }) => VNode;
182
230
  }>();
183
231
 
184
232
  const table = useTemplateRef('table');
185
233
  const treeDisabled = useDisabledInjection();
234
+ const translate = useTranslate();
186
235
 
187
236
  const limitedItems = computed(() => items.slice(0, perPage));
188
237
 
238
+ const hasExpandable = computed(() => 'expandable' in slots);
239
+ const columnCount = computed(() => {
240
+ const userColumns = Object.keys(slots).filter(name => !IGNORED_SLOTS.includes(name)).length;
241
+ return userColumns + (selectionMode ? 1 : 0) + (unref(hasExpandable) ? 1 : 0);
242
+ });
243
+
189
244
  const currentPageIds = computed<SelectionId[]>(() => {
190
245
  if (!uniqueKey) {
191
246
  return [];
@@ -294,10 +349,36 @@
294
349
  selected.value = current.filter(id => !ids.includes(id));
295
350
  }
296
351
 
352
+ function isItemExpanded(item: T): boolean {
353
+ const id = getItemId(item);
354
+ return id !== undefined && unref(expanded).includes(id);
355
+ }
356
+
357
+ function toggleExpand(item: T): void {
358
+ const id = getItemId(item);
359
+
360
+ if (id === undefined) {
361
+ return;
362
+ }
363
+
364
+ const current = unref(expanded);
365
+
366
+ if (current.includes(id)) {
367
+ expanded.value = current.filter(v => v !== id);
368
+ return;
369
+ }
370
+
371
+ expanded.value = expandMode === 'single' ? [id] : [...current, id];
372
+ }
373
+
297
374
  if (import.meta.env.DEV && selectionMode && !uniqueKey) {
298
375
  console.warn('[FluxDataTable] `uniqueKey` is required when `selectionMode` is set, otherwise rows cannot be tracked across renders.');
299
376
  }
300
377
 
378
+ if (import.meta.env.DEV && unref(hasExpandable) && !uniqueKey) {
379
+ console.warn('[FluxDataTable] `uniqueKey` is required when the `expandable` slot is used, otherwise rows cannot be tracked across renders.');
380
+ }
381
+
301
382
  watch(() => items, () => {
302
383
  unref(table)?.$el.scrollTo(0, 0);
303
384
  });
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <div
3
+ :class="clsx(
4
+ $style.descriptionItem,
5
+ isStacked && $style.isStacked
6
+ )">
7
+ <dt :class="$style.descriptionItemTerm">
8
+ <FluxIcon
9
+ v-if="icon"
10
+ :class="$style.descriptionItemIcon"
11
+ :name="icon"/>
12
+
13
+ <span :class="$style.descriptionItemLabel">
14
+ <slot name="label">{{ label }}</slot>
15
+ </span>
16
+ </dt>
17
+
18
+ <dd :class="$style.descriptionItemValue">
19
+ <slot/>
20
+ </dd>
21
+ </div>
22
+ </template>
23
+
24
+ <script
25
+ lang="ts"
26
+ setup>
27
+ import type { FluxIconName } from '@flux-ui/types';
28
+ import { clsx } from 'clsx';
29
+ import type { VNode } from 'vue';
30
+ import FluxIcon from './FluxIcon.vue';
31
+ import $style from '~flux/components/css/component/DescriptionList.module.scss';
32
+
33
+ defineProps<{
34
+ readonly icon?: FluxIconName;
35
+ readonly isStacked?: boolean;
36
+ readonly label?: string;
37
+ }>();
38
+
39
+ defineSlots<{
40
+ default(): VNode[];
41
+ label(): VNode[];
42
+ }>();
43
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div :class="$style.descriptionList">
3
+ <div
4
+ v-if="title || slots.header"
5
+ :class="$style.descriptionListHeader">
6
+ <slot name="header">{{ title }}</slot>
7
+ </div>
8
+
9
+ <dl
10
+ :class="clsx(
11
+ $style.descriptionListItems,
12
+ direction === 'horizontal' && $style.isHorizontal
13
+ )">
14
+ <slot/>
15
+ </dl>
16
+ </div>
17
+ </template>
18
+
19
+ <script
20
+ lang="ts"
21
+ setup>
22
+ import { clsx } from 'clsx';
23
+ import type { VNode } from 'vue';
24
+ import $style from '~flux/components/css/component/DescriptionList.module.scss';
25
+
26
+ const {
27
+ direction = 'vertical'
28
+ } = defineProps<{
29
+ readonly direction?: 'horizontal' | 'vertical';
30
+ readonly title?: string;
31
+ }>();
32
+
33
+ const slots = defineSlots<{
34
+ default(): VNode[];
35
+ header(): VNode[];
36
+ }>();
37
+ </script>
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <SelectBase
3
+ v-model:searchQuery="modelSearch"
4
+ :aria-invalid="error ? true : undefined"
5
+ :class="clsx(
6
+ isCondensed && $formStyle.isCondensed,
7
+ isSecondary && $formStyle.isSecondary,
8
+ error && $formStyle.isInvalid
9
+ )"
10
+ :disabled="disabled"
11
+ is-searchable
12
+ :is-creatable="isCreatable"
13
+ :is-loading="isLoading"
14
+ :is-multiple="isMultiple"
15
+ :options="groups"
16
+ :placeholder="placeholder"
17
+ :selected="selected"
18
+ @create="onCreate"
19
+ @deselect="onDeselect"
20
+ @select="onSelect"/>
21
+ </template>
22
+
23
+ <script
24
+ lang="ts"
25
+ setup>
26
+ import type { FluxFormInputBaseProps, FluxFormSelectEntry, FluxFormSelectOption, FluxFormSelectValue } from '@flux-ui/types';
27
+ import { clsx } from 'clsx';
28
+ import { computed, ref, toRef, unref } from 'vue';
29
+ import { SelectBase } from '~flux/components/component/primitive';
30
+ import { useDisabled } from '~flux/components/composable';
31
+ import { useFormSelect } from '~flux/components/composable/private';
32
+ import { isFluxFormSelectGroup, isFluxFormSelectOption } from '~flux/components/data';
33
+ import $formStyle from '~flux/components/css/component/Form.module.scss';
34
+
35
+ const modelSearch = defineModel<string>('searchQuery', {
36
+ default: ''
37
+ });
38
+
39
+ const modelValue = defineModel<FluxFormSelectValue>({
40
+ required: true
41
+ });
42
+
43
+ const {
44
+ disabled: componentDisabled,
45
+ isMultiple,
46
+ options
47
+ } = defineProps<Pick<FluxFormInputBaseProps, 'autoFocus' | 'disabled' | 'error' | 'isCondensed' | 'isLoading' | 'isReadonly' | 'isSecondary' | 'name' | 'placeholder'> & {
48
+ readonly isCreatable?: boolean;
49
+ readonly isMultiple?: boolean;
50
+ readonly options: FluxFormSelectEntry[];
51
+ }>();
52
+
53
+ const disabled = useDisabled(toRef(() => componentDisabled));
54
+ const createdOptions = ref<FluxFormSelectOption[]>([]);
55
+
56
+ const allOptions = computed<FluxFormSelectEntry[]>(() => {
57
+ const seen = new Set<string | number | null>();
58
+
59
+ return [...options, ...createdOptions.value].filter(o => {
60
+ if (isFluxFormSelectGroup(o)) {
61
+ return true;
62
+ }
63
+
64
+ if (seen.has(o.value)) {
65
+ return false;
66
+ }
67
+
68
+ seen.add(o.value);
69
+ return true;
70
+ });
71
+ });
72
+
73
+ const {groups, selected, values} = useFormSelect(modelValue, isMultiple, allOptions, modelSearch);
74
+
75
+ function onDeselect(id: string | number | null): void {
76
+ if (isMultiple) {
77
+ modelValue.value = unref(values).filter(v => v !== id);
78
+ }
79
+ }
80
+
81
+ function onSelect(id: string | number | null): void {
82
+ if (isMultiple) {
83
+ modelValue.value = [...unref(values), id];
84
+ } else {
85
+ modelValue.value = id;
86
+ }
87
+ }
88
+
89
+ function onCreate(query: string): void {
90
+ const exists = unref(allOptions).some(o => isFluxFormSelectOption(o) && o.value === query);
91
+
92
+ if (!exists) {
93
+ createdOptions.value = [...createdOptions.value, {label: query, value: query}];
94
+ }
95
+
96
+ onSelect(query);
97
+ }
98
+ </script>