@globalbrain/sefirot 4.57.0 → 4.59.0

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 (55) hide show
  1. package/lib/blocks/lens/components/LensCatalog.vue +55 -32
  2. package/lib/blocks/lens/components/LensFormFilterCondition.vue +2 -1
  3. package/lib/blocks/lens/components/LensFormFilterGroup.vue +1 -1
  4. package/lib/blocks/lens/components/{LensAvatarInput.vue → LensInputAvatar.vue} +5 -3
  5. package/lib/blocks/lens/components/LensInputDropdown.vue +102 -0
  6. package/lib/blocks/lens/components/LensSheetAvatarField.vue +2 -2
  7. package/lib/blocks/lens/components/LensTable.vue +9 -6
  8. package/lib/blocks/lens/components/LensTableAvatarCell.vue +2 -2
  9. package/lib/blocks/lens/components/LensTableEditableCell.vue +8 -7
  10. package/lib/blocks/lens/composables/LensEdit.ts +9 -0
  11. package/lib/blocks/lens/fields/AvatarField.ts +2 -2
  12. package/lib/blocks/lens/fields/Field.ts +10 -6
  13. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +2 -1
  14. package/lib/components/SButton.vue +2 -10
  15. package/lib/components/SControlInputSearch.vue +6 -3
  16. package/lib/components/SDescPill.vue +3 -2
  17. package/lib/components/SDescState.vue +3 -2
  18. package/lib/components/SInputAddon.vue +4 -2
  19. package/lib/components/{SInputSelectSearch.vue → SInputAsyncDropdown.vue} +91 -83
  20. package/lib/components/SInputBase.vue +1 -3
  21. package/lib/components/SInputCheckbox.vue +10 -18
  22. package/lib/components/SInputCheckboxes.vue +16 -33
  23. package/lib/components/SInputDate.vue +10 -20
  24. package/lib/components/SInputDropdown.vue +22 -43
  25. package/lib/components/SInputDropdownItem.vue +7 -24
  26. package/lib/components/SInputFile.vue +10 -18
  27. package/lib/components/SInputFileUpload.vue +12 -34
  28. package/lib/components/SInputFileUploadItem.vue +7 -21
  29. package/lib/components/SInputHMS.vue +6 -7
  30. package/lib/components/SInputImage.vue +11 -16
  31. package/lib/components/SInputNumber.vue +1 -3
  32. package/lib/components/SInputRadio.vue +9 -19
  33. package/lib/components/SInputRadios.vue +24 -40
  34. package/lib/components/SInputSegments.vue +15 -26
  35. package/lib/components/SInputSegmentsOption.vue +7 -7
  36. package/lib/components/SInputSelect.vue +14 -19
  37. package/lib/components/SInputSwitch.vue +10 -19
  38. package/lib/components/SInputSwitches.vue +16 -30
  39. package/lib/components/SInputText.vue +1 -4
  40. package/lib/components/SInputTextarea.vue +0 -2
  41. package/lib/components/SInputYMD.vue +6 -7
  42. package/lib/components/SPagination.vue +1 -1
  43. package/lib/components/SPill.vue +2 -2
  44. package/lib/components/SState.vue +2 -2
  45. package/lib/components/STableCellPill.vue +3 -2
  46. package/lib/components/STableCellState.vue +3 -2
  47. package/lib/composables/Table.ts +8 -15
  48. package/lib/support/Color.ts +4 -0
  49. package/lib/support/InputBase.ts +5 -0
  50. package/lib/support/InputDropdown.ts +15 -0
  51. package/lib/support/InputFileUpload.ts +22 -0
  52. package/lib/support/InputOption.ts +8 -0
  53. package/lib/support/InputText.ts +6 -0
  54. package/package.json +1 -1
  55. package/lib/blocks/lens/components/LensInputSelect.vue +0 -92
@@ -1,51 +1,40 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T, Multiple extends boolean = false">
2
2
  import IconCaretDown from '~icons/ph/caret-down'
3
3
  import IconCaretUp from '~icons/ph/caret-up'
4
4
  import IconCheck from '~icons/ph/check'
5
- import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
5
+ import { type Ref, computed, nextTick, onUnmounted, ref, watch } from 'vue'
6
6
  import { useManualDropdownPosition } from '../composables/Dropdown'
7
7
  import { useFlyout } from '../composables/Flyout'
8
8
  import { useTrans } from '../composables/Lang'
9
+ import { type Option } from '../support/InputDropdown'
9
10
  import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
10
11
  import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
11
12
  import SInputDropdownItem from './SInputDropdownItem.vue'
12
13
  import SSpinner from './SSpinner.vue'
13
14
 
14
- export type { Color, Size } from './SInputBase.vue'
15
-
16
- export type Option = OptionText | OptionAvatar
17
-
18
- export interface OptionBase {
19
- type?: 'text' | 'avatar'
20
- value: any
21
- disabled?: boolean
22
- }
23
-
24
- export interface OptionText extends OptionBase {
25
- type?: 'text'
26
- label: string
27
- }
28
-
29
- export interface OptionAvatar extends OptionBase {
30
- type: 'avatar'
31
- label: string
32
- image?: string | null
33
- }
34
-
35
- export interface Props extends BaseProps {
15
+ export interface Props<T = any, Multiple extends boolean = false> extends BaseProps {
36
16
  placeholder?: string
37
- // Fetch the options matching `query` from the server. Called when the dropdown
17
+ // Fetch the items matching `query` from the server. Called when the dropdown
38
18
  // opens (with the current query, usually empty for the initial list) and again,
39
- // debounced, on each keystroke. Out-of-order responses are discarded, so it does
40
- // not need to guard against races itself.
41
- fetch: (query: string) => Promise<Option[]>
19
+ // debounced, on each keystroke. Out-of-order responses are discarded, so it
20
+ // does not need to guard against races itself.
21
+ fetch: (query: string) => Promise<T[]>
22
+ // Render an item as its dropdown option and, once selected, as its chip. The
23
+ // option's `value` is the item's identity — selection, de-duplication and
24
+ // removal all key off it. Applied to fetched items and to the bound model
25
+ // alike, so a seeded selection renders without appearing in any fetch.
26
+ toOption: (item: T) => Option
42
27
  // Decide whether a `fetch` rejection should propagate (be re-thrown) instead of
43
28
  // being swallowed into an empty list — e.g. to let auth/session failures reach
44
29
  // the app's re-auth flow. By default every rejection is swallowed.
45
30
  rethrow?: (error: unknown) => boolean
46
- // Whether multiple options can be selected. The model is then an array of the
47
- // selected options; otherwise it is a single option or `null`.
48
- multiple?: boolean
31
+ // Whether multiple items can be selected. The model is then an array of items;
32
+ // otherwise it is a single item or `null`. Typed `boolean & Multiple` (equal to
33
+ // `Multiple`, since it extends `boolean`) rather than a bare `Multiple`: the
34
+ // intersection keeps Vue's runtime Boolean casting (a bare `multiple` attribute
35
+ // would otherwise arrive as `''` and read as false), while `Multiple` still
36
+ // drives the arity-conditional model type.
37
+ multiple?: boolean & Multiple
49
38
  // Whether the (single) value can be cleared, or the (multiple) selection emptied.
50
39
  nullable?: boolean
51
40
  disabled?: boolean
@@ -60,12 +49,18 @@ export interface Props extends BaseProps {
60
49
  // `closeOnSelect` defaults to `undefined` (not Vue's usual absent-Boolean → false)
61
50
  // so the `?? !multiple` fallback in onSelect can tell "omitted" from an explicit
62
51
  // `false`, keeping the single-select-closes / multiple-stays-open default.
63
- const props = withDefaults(defineProps<Props>(), { closeOnSelect: undefined })
52
+ const props = withDefaults(defineProps<Props<T, Multiple>>(), { closeOnSelect: undefined })
64
53
 
65
- // The selected option(s). A single `Option | null`, or an `Option[]` when
66
- // `multiple`. Typed loosely (like SInputDropdown) so a consumer can bind a ref of
67
- // its own richer option type without v-model variance friction.
68
- const model = defineModel<any>({ required: true })
54
+ // The selection: an array of items when `multiple`, otherwise a single item or
55
+ // `null`. Items are the consumer's own model type; the option rendered for each
56
+ // is derived on demand via `toOption`, so nothing extra is stored on the model.
57
+ const model = defineModel<Multiple extends true ? T[] : T | null>({ required: true })
58
+
59
+ // Write the model regardless of arity. The public type is arity-conditional, so
60
+ // the concrete array / single value is cast at this single boundary.
61
+ function commit(value: T[] | T | null): void {
62
+ model.value = value as typeof model.value
63
+ }
69
64
 
70
65
  const { t } = useTrans({
71
66
  en: {
@@ -91,10 +86,10 @@ const { inset, update: updatePosition } = useManualDropdownPosition(
91
86
  )
92
87
 
93
88
  const query = ref('')
94
- const options = ref<Option[]>([])
89
+ const items = ref([]) as Ref<T[]>
95
90
  const loading = ref(false)
96
91
 
97
- // Monotonic token so only the latest fetch may assign `options` / clear `loading`.
92
+ // Monotonic token so only the latest fetch may assign `items` / clear `loading`.
98
93
  // With a debounced, user-driven search several requests can overlap; an older one
99
94
  // resolving late must not clobber the newer result.
100
95
  let fetchSeq = 0
@@ -104,20 +99,38 @@ const classes = computed(() => [
104
99
  { disabled: props.disabled }
105
100
  ])
106
101
 
107
- const selected = computed<Option | Option[] | null>(() => {
102
+ // The selected items as a flat array, regardless of arity, for internal handling.
103
+ const selectedItems = computed<T[]>(() => {
108
104
  if (props.multiple) {
109
- return Array.isArray(model.value) ? (model.value as Option[]) : []
105
+ return Array.isArray(model.value) ? (model.value as T[]) : []
110
106
  }
111
- return (model.value as Option | null) ?? null
107
+ return model.value != null ? [model.value as T] : []
112
108
  })
113
109
 
114
- const hasSelected = computed(() =>
115
- props.multiple ? (selected.value as Option[]).length > 0 : selected.value !== null
110
+ // The identities currently selected, for O(1) active / de-dup checks.
111
+ const selectedValues = computed(
112
+ () => new Set(selectedItems.value.map((item) => props.toOption(item).value))
116
113
  )
117
114
 
115
+ // The option(s) rendered for the current selection: an array of chips when
116
+ // multiple, a single chip otherwise (matching SInputDropdownItem's `item`).
117
+ const selectedOptions = computed<Option | Option[] | null>(() => {
118
+ if (props.multiple) {
119
+ return selectedItems.value.map((item) => props.toOption(item))
120
+ }
121
+ return model.value != null ? props.toOption(model.value as T) : null
122
+ })
123
+
124
+ // The fetched items paired with their rendered option, for the dropdown list.
125
+ const optionItems = computed(() =>
126
+ items.value.map((item) => ({ item, option: props.toOption(item) }))
127
+ )
128
+
129
+ const hasSelected = computed(() => selectedItems.value.length > 0)
130
+
118
131
  const removable = computed(() => {
119
132
  if (props.multiple) {
120
- return !!props.nullable || (selected.value as Option[]).length > 1
133
+ return !!props.nullable || selectedItems.value.length > 1
121
134
  }
122
135
  return !!props.nullable
123
136
  })
@@ -128,12 +141,12 @@ async function runFetch(q: string): Promise<void> {
128
141
  try {
129
142
  const res = await props.fetch(q)
130
143
  if (seq === fetchSeq) {
131
- options.value = res
144
+ items.value = res
132
145
  }
133
146
  } catch (e) {
134
147
  // A failed search yields no options (the empty state shows)…
135
148
  if (seq === fetchSeq) {
136
- options.value = []
149
+ items.value = []
137
150
  }
138
151
  // …unless the consumer wants this failure propagated (e.g. an auth/session
139
152
  // error that must reach the app's re-auth flow rather than look like "no
@@ -199,14 +212,14 @@ function onOpen() {
199
212
  watch(isOpen, (value) => {
200
213
  // On close: drop any pending refetch, invalidate any in-flight fetch (so a late
201
214
  // response can't repopulate the list while closed), and clear the query /
202
- // options / loading. Reopening then starts fresh instead of briefly rendering
215
+ // items / loading. Reopening then starts fresh instead of briefly rendering
203
216
  // the previous query's results (which would be selectable). The selected chips
204
- // are unaffected — they render from the model, not from `options`.
217
+ // are unaffected — they render from the model, not from the fetched `items`.
205
218
  if (!value) {
206
219
  cancelPendingFetch()
207
220
  fetchSeq++
208
221
  query.value = ''
209
- options.value = []
222
+ items.value = []
210
223
  loading.value = false
211
224
  }
212
225
  })
@@ -223,36 +236,32 @@ watch(() => props.disabled, (disabled) => {
223
236
  })
224
237
 
225
238
  function isActive(value: any): boolean {
226
- if (props.multiple) {
227
- return (selected.value as Option[]).some((o) => o.value === value)
228
- }
229
- return (selected.value as Option | null)?.value === value
239
+ return selectedValues.value.has(value)
230
240
  }
231
241
 
232
- function onSelect(option: Option): void {
242
+ function onSelect(item: T): void {
243
+ const option = props.toOption(item)
233
244
  if (props.disabled || option.disabled) {
234
245
  return
235
246
  }
236
247
 
237
248
  props.validation?.$touch()
238
249
 
250
+ const value = option.value
251
+
239
252
  if (props.multiple) {
240
- const current = (model.value as Option[] | null) ?? []
241
- const exists = current.some((o) => o.value === option.value)
242
- const next = exists
243
- ? current.filter((o) => o.value !== option.value)
244
- : [...current, option]
253
+ const current = selectedItems.value
254
+ const next = selectedValues.value.has(value)
255
+ ? current.filter((i) => props.toOption(i).value !== value)
256
+ : [...current, item]
245
257
  // Keep at least one unless clearing is allowed (mirrors SInputDropdown).
246
258
  if (next.length !== 0 || props.nullable) {
247
- model.value = next
248
- }
249
- } else {
250
- const currentValue = (model.value as Option | null)?.value
251
- if (currentValue !== option.value) {
252
- model.value = option
253
- } else if (props.nullable) {
254
- model.value = null
259
+ commit(next)
255
260
  }
261
+ } else if (!selectedValues.value.has(value)) {
262
+ commit(item)
263
+ } else if (props.nullable) {
264
+ commit(null)
256
265
  }
257
266
 
258
267
  if (props.closeOnSelect ?? !props.multiple) {
@@ -265,10 +274,9 @@ function onRemove(value: any): void {
265
274
  props.validation?.$touch()
266
275
 
267
276
  if (props.multiple) {
268
- const current = (model.value as Option[] | null) ?? []
269
- model.value = current.filter((o) => o.value !== value)
277
+ commit(selectedItems.value.filter((item) => props.toOption(item).value !== value))
270
278
  } else {
271
- model.value = null
279
+ commit(null)
272
280
  }
273
281
  }
274
282
 
@@ -306,7 +314,7 @@ function focusNext(event: any): void {
306
314
 
307
315
  <template>
308
316
  <SInputBase
309
- class="SInputSelectSearch"
317
+ class="SInputAsyncDropdown"
310
318
  :class="classes"
311
319
  :size
312
320
  :name
@@ -335,7 +343,7 @@ function focusNext(event: any): void {
335
343
  <div class="box-content">
336
344
  <SInputDropdownItem
337
345
  v-if="hasSelected"
338
- :item="selected!"
346
+ :item="selectedOptions!"
339
347
  :size="size ?? 'small'"
340
348
  :removable
341
349
  :disabled="disabled ?? false"
@@ -369,11 +377,11 @@ function focusNext(event: any): void {
369
377
  >
370
378
  <!-- A refetch while options are already shown keeps the (stale) list
371
379
  visible; this spinner signals that newer results are loading. -->
372
- <SSpinner v-if="loading && options.length" class="search-spinner" />
380
+ <SSpinner v-if="loading && items.length" class="search-spinner" />
373
381
  </div>
374
382
 
375
- <ul v-if="options.length" ref="list" class="list">
376
- <li v-for="option in options" :key="option.value" class="item">
383
+ <ul v-if="items.length" ref="list" class="list">
384
+ <li v-for="{ item, option } in optionItems" :key="option.value" class="item">
377
385
  <button
378
386
  class="button"
379
387
  :class="{ active: isActive(option.value) }"
@@ -382,7 +390,7 @@ function focusNext(event: any): void {
382
390
  tabindex="0"
383
391
  @keyup.up.prevent="focusPrev"
384
392
  @keyup.down.prevent="focusNext"
385
- @click="onSelect(option)"
393
+ @click="onSelect(item)"
386
394
  >
387
395
  <span v-if="multiple" class="checkbox">
388
396
  <span class="checkbox-box">
@@ -646,8 +654,8 @@ function focusNext(event: any): void {
646
654
  color: var(--c-text-2);
647
655
  }
648
656
 
649
- .SInputSelectSearch.sm,
650
- .SInputSelectSearch.mini {
657
+ .SInputAsyncDropdown.sm,
658
+ .SInputAsyncDropdown.mini {
651
659
  .box {
652
660
  min-height: 32px;
653
661
  }
@@ -668,7 +676,7 @@ function focusNext(event: any): void {
668
676
  }
669
677
  }
670
678
 
671
- .SInputSelectSearch.md {
679
+ .SInputAsyncDropdown.md {
672
680
  .box {
673
681
  min-height: 36px;
674
682
  }
@@ -689,7 +697,7 @@ function focusNext(event: any): void {
689
697
  }
690
698
  }
691
699
 
692
- .SInputSelectSearch.small {
700
+ .SInputAsyncDropdown.small {
693
701
  .box {
694
702
  min-height: 40px;
695
703
  }
@@ -710,7 +718,7 @@ function focusNext(event: any): void {
710
718
  }
711
719
  }
712
720
 
713
- .SInputSelectSearch.medium {
721
+ .SInputAsyncDropdown.medium {
714
722
  .box {
715
723
  min-height: 48px;
716
724
  }
@@ -731,7 +739,7 @@ function focusNext(event: any): void {
731
739
  }
732
740
  }
733
741
 
734
- .SInputSelectSearch.disabled {
742
+ .SInputAsyncDropdown.disabled {
735
743
  .box {
736
744
  border-color: var(--input-disabled-border-color);
737
745
  background-color: var(--input-disabled-bg-color);
@@ -745,7 +753,7 @@ function focusNext(event: any): void {
745
753
  }
746
754
  }
747
755
 
748
- .SInputSelectSearch.has-error {
756
+ .SInputAsyncDropdown.has-error {
749
757
  .box {
750
758
  border-color: var(--input-error-border-color);
751
759
  }
@@ -2,6 +2,7 @@
2
2
  import IconQuestion from '~icons/ph/question'
3
3
  import { type Component, computed, unref, useSlots } from 'vue'
4
4
  import { type Validatable } from '../composables/Validation'
5
+ import { type Color, type Size } from '../support/InputBase'
5
6
  import STooltip from './STooltip.vue'
6
7
 
7
8
  export interface Props {
@@ -20,9 +21,6 @@ export interface Props {
20
21
  hideWarning?: boolean
21
22
  }
22
23
 
23
- export type Size = 'sm' | 'md' | 'mini' | 'small' | 'medium'
24
- export type Color = 'neutral' | 'mute' | 'info' | 'success' | 'warning' | 'danger'
25
-
26
24
  const props = defineProps<Props>()
27
25
 
28
26
  const slots = useSlots()
@@ -1,28 +1,17 @@
1
1
  <script setup lang="ts">
2
2
  import IconCheck from '~icons/ph/check-bold'
3
3
  import IconMinus from '~icons/ph/minus-bold'
4
- import { type Component, computed } from 'vue'
5
- import { type Validatable } from '../composables/Validation'
6
- import SInputBase, { type Color, type Size } from './SInputBase.vue'
7
-
8
- export type { Color, Size }
9
-
10
- const props = withDefaults(defineProps<{
11
- size?: Size
12
- label?: string
13
- info?: string
14
- note?: string
15
- help?: string
16
- checkIcon?: Component
17
- checkText?: string
18
- checkColor?: Color
4
+ import { computed } from 'vue'
5
+ import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
6
+
7
+ export interface Props extends BaseProps {
19
8
  text?: string
20
9
  disabled?: boolean
21
10
  value?: boolean | 'indeterminate'
22
11
  modelValue?: boolean | 'indeterminate'
23
- validation?: Validatable
24
- hideError?: boolean
25
- }>(), {
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
26
15
  value: undefined,
27
16
  modelValue: undefined
28
17
  })
@@ -62,6 +51,7 @@ function onClick() {
62
51
  class="SInputCheckbox"
63
52
  :class="classes"
64
53
  :size
54
+ :name
65
55
  :label
66
56
  :note
67
57
  :info
@@ -70,7 +60,9 @@ function onClick() {
70
60
  :check-text
71
61
  :check-color
72
62
  :validation
63
+ :warning
73
64
  :hide-error
65
+ :hide-warning
74
66
  >
75
67
  <div class="container">
76
68
  <div
@@ -1,43 +1,24 @@
1
- <script setup lang="ts">
2
- import { type Component, computed } from 'vue'
3
- import { type Validatable } from '../composables/Validation'
4
- import SInputBase, { type Color, type Size } from './SInputBase.vue'
1
+ <script setup lang="ts" generic="T = any">
2
+ import { computed } from 'vue'
3
+ import { type Option } from '../support/InputOption'
4
+ import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
5
5
  import SInputCheckbox from './SInputCheckbox.vue'
6
6
 
7
- export type { Color, Size }
8
-
9
- export type Value = any
10
-
11
- export interface Option {
12
- label: string
13
- value: Value
7
+ export interface Props<T = any> extends BaseProps {
8
+ options: Option<T>[]
9
+ nullable?: boolean
14
10
  disabled?: boolean
11
+ value?: T[]
12
+ modelValue?: T[]
15
13
  }
16
14
 
17
- const props = withDefaults(defineProps<{
18
- size?: Size
19
- name?: string
20
- label?: string
21
- info?: string
22
- note?: string
23
- help?: string
24
- checkIcon?: Component
25
- checkText?: string
26
- checkColor?: Color
27
- options: Option[]
28
- nullable?: boolean
29
- disabled?: boolean
30
- value?: Value[]
31
- modelValue?: Value[]
32
- validation?: Validatable
33
- hideError?: boolean
34
- }>(), {
15
+ const props = withDefaults(defineProps<Props<T>>(), {
35
16
  nullable: true
36
17
  })
37
18
 
38
19
  const emit = defineEmits<{
39
- 'update:model-value': [value: Value[]]
40
- 'change': [value: Value[]]
20
+ 'update:model-value': [value: T[]]
21
+ 'change': [value: T[]]
41
22
  }>()
42
23
 
43
24
  const _value = computed(() => {
@@ -48,11 +29,11 @@ const _value = computed(() => {
48
29
  : []
49
30
  })
50
31
 
51
- function isChecked(value: Value): boolean {
32
+ function isChecked(value: T): boolean {
52
33
  return _value.value.includes(value)
53
34
  }
54
35
 
55
- function onChange(value: Value): void {
36
+ function onChange(value: T): void {
56
37
  const distinct = _value.value
57
38
  .filter((v) => v !== value)
58
39
  .concat(_value.value.includes(value) ? [] : [value])
@@ -80,7 +61,9 @@ function onChange(value: Value): void {
80
61
  :check-text
81
62
  :check-color
82
63
  :validation
64
+ :warning
83
65
  :hide-error
66
+ :hide-warning
84
67
  >
85
68
  <div class="container">
86
69
  <div class="row">
@@ -1,29 +1,17 @@
1
1
  <script setup lang="ts">
2
2
  import { DatePicker } from 'v-calendar'
3
- import { type Component, computed } from 'vue'
4
- import { type Validatable } from '../composables/Validation'
3
+ import { computed } from 'vue'
5
4
  import { type Day, day } from '../support/Day'
6
- import SInputBase, { type Color, type Size } from './SInputBase.vue'
7
-
8
- export type { Color, Size }
9
-
10
- const props = defineProps<{
11
- size?: Size
12
- name?: string
13
- label?: string
14
- info?: string
15
- note?: string
16
- help?: string
17
- checkIcon?: Component
18
- checkText?: string
19
- checkColor?: Color
5
+ import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
6
+
7
+ export interface Props extends BaseProps {
20
8
  block?: boolean
21
9
  disabled?: boolean
22
10
  tabindex?: -1 | 0 | number
23
11
  modelValue: Day | null
24
- validation?: Validatable
25
- hideError?: boolean
26
- }>()
12
+ }
13
+
14
+ const props = defineProps<Props>()
27
15
 
28
16
  const emit = defineEmits<{
29
17
  'update:model-value': [value: Day | null]
@@ -61,8 +49,10 @@ function onBlur() {
61
49
  :check-icon
62
50
  :check-text
63
51
  :check-color
64
- :hide-error
65
52
  :validation
53
+ :warning
54
+ :hide-error
55
+ :hide-warning
66
56
  >
67
57
  <div class="container">
68
58
  <DatePicker
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="T = any">
2
2
  import IconCaretDown from '~icons/ph/caret-down'
3
3
  import IconCaretUp from '~icons/ph/caret-up'
4
4
  import xor from 'lodash-es/xor'
@@ -6,14 +6,14 @@ import { computed, ref } from 'vue'
6
6
  import { type DropdownSectionFilter, useManualDropdownPosition } from '../composables/Dropdown'
7
7
  import { useFlyout } from '../composables/Flyout'
8
8
  import { useTrans } from '../composables/Lang'
9
+ import { type Option } from '../support/InputDropdown'
9
10
  import SDropdown from './SDropdown.vue'
10
11
  import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
11
12
  import SInputDropdownItem from './SInputDropdownItem.vue'
12
13
 
13
- export type { Color, Size } from './SInputBase.vue'
14
- export interface Props extends BaseProps {
14
+ export interface Props<T = any> extends BaseProps {
15
15
  placeholder?: string
16
- options: Option[]
16
+ options: Option<T>[]
17
17
  position?: 'top' | 'bottom'
18
18
  noSearch?: boolean
19
19
  nullable?: boolean
@@ -21,32 +21,12 @@ export interface Props extends BaseProps {
21
21
  disabled?: boolean
22
22
  }
23
23
 
24
- export type PrimitiveValue = any
25
- export type ArrayValue = any[]
26
- export type OptionValue = any
24
+ const props = defineProps<Props<T>>()
27
25
 
28
- export type Option = OptionText | OptionAvatar
29
-
30
- export interface OptionBase {
31
- type?: 'text' | 'avatar'
32
- value: OptionValue
33
- disabled?: boolean
34
- }
35
-
36
- export interface OptionText extends OptionBase {
37
- type?: 'text'
38
- label: string
39
- }
40
-
41
- export interface OptionAvatar extends OptionBase {
42
- type: 'avatar'
43
- label: string
44
- image?: string | null
45
- }
46
-
47
- const props = defineProps<Props>()
48
-
49
- const model = defineModel<PrimitiveValue | ArrayValue>({ required: true })
26
+ // Single- vs multi-select is inferred from the bound model at runtime: an array
27
+ // model selects multiple values, anything else a single value (or `null`) — so no
28
+ // `multiple` prop is needed (unlike SInputAsyncDropdown).
29
+ const model = defineModel<T | T[] | null>({ required: true })
50
30
 
51
31
  const { t } = useTrans({
52
32
  en: {
@@ -78,14 +58,12 @@ const dropdownOptions = computed<DropdownSectionFilter[]>(() => [{
78
58
  onClick: onSelect
79
59
  }])
80
60
 
81
- const selected = computed(() => {
61
+ const selected = computed<Option<T> | Option<T>[] | null>(() => {
82
62
  if (Array.isArray(model.value)) {
83
- return props.options.filter((o) => (model.value as ArrayValue).includes(o.value))
63
+ return props.options.filter((o) => (model.value as T[]).includes(o.value))
84
64
  }
85
65
 
86
- const item = props.options.find((o) => o.value === model.value)
87
-
88
- return item ?? null
66
+ return props.options.find((o) => o.value === model.value) ?? null
89
67
  })
90
68
 
91
69
  const hasSelected = computed(() => {
@@ -94,7 +72,7 @@ const hasSelected = computed(() => {
94
72
 
95
73
  const removable = computed(() => {
96
74
  if (Array.isArray(model.value)) {
97
- return props.nullable || (selected.value as Option[]).length > 1
75
+ return !!props.nullable || (selected.value as Option<T>[]).length > 1
98
76
  }
99
77
 
100
78
  return !!props.nullable
@@ -107,20 +85,18 @@ async function onOpen() {
107
85
  }
108
86
  }
109
87
 
110
- function onSelect(value: OptionValue) {
88
+ function onSelect(value: T) {
111
89
  props.validation?.$touch()
112
90
 
113
91
  if (Array.isArray(model.value)) {
114
- const toggled = xor(model.value, [value])
92
+ const toggled = xor(model.value as T[], [value])
115
93
  if (toggled.length !== 0 || props.nullable) {
116
94
  model.value = toggled
117
95
  }
118
- } else {
119
- if (value !== model.value) {
120
- model.value = value
121
- } else if (props.nullable) {
122
- model.value = null
123
- }
96
+ } else if (value !== model.value) {
97
+ model.value = value
98
+ } else if (props.nullable) {
99
+ model.value = null
124
100
  }
125
101
 
126
102
  props.closeOnClick && close()
@@ -132,6 +108,7 @@ function onSelect(value: OptionValue) {
132
108
  class="SInputDropdown"
133
109
  :class="classes"
134
110
  :size
111
+ :name
135
112
  :label
136
113
  :note
137
114
  :info
@@ -140,7 +117,9 @@ function onSelect(value: OptionValue) {
140
117
  :check-text
141
118
  :check-color
142
119
  :validation
120
+ :warning
143
121
  :hide-error
122
+ :hide-warning
144
123
  >
145
124
  <div ref="container" class="container">
146
125
  <div