@globalbrain/sefirot 4.59.1 → 4.61.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.
@@ -222,13 +222,20 @@ export abstract class Field<T extends FieldData> {
222
222
  * Helper function to define the data list item component.
223
223
  */
224
224
  protected defineDataListItemComponent(fn: (value: any) => any, options: any = null): any {
225
- return defineComponent((props) => {
226
- return () => h(SDataListItem, options, {
225
+ return defineComponent((props, { emit }) => {
226
+ return () => h(SDataListItem, {
227
+ ...(options ?? {}),
228
+ 'valueAction': props.valueAction,
229
+ 'onClick:value': (event: MouseEvent) => {
230
+ emit('click:value', event)
231
+ }
232
+ }, {
227
233
  label: () => this.label(),
228
234
  value: () => fn(props.value)
229
235
  })
230
236
  }, {
231
- props: ['value']
237
+ props: ['value', 'valueAction'],
238
+ emits: ['click:value']
232
239
  })
233
240
  }
234
241
 
@@ -255,10 +262,21 @@ export abstract class Field<T extends FieldData> {
255
262
  }
256
263
 
257
264
  /**
258
- * Returns the value should be used when the input is empty.
265
+ * The value an empty input starts from: the definition's `emptyValue`, a
266
+ * wire-format payload value run through `payloadToInput`. The server owns
267
+ * the blank entirely — field types with a non-null blank (a multiple
268
+ * select's `[]`, say) declare it in their definition, so there are no
269
+ * per-type overrides here. Only null/undefined fall back to a null blank,
270
+ * so `false` and `0` are real values.
271
+ *
272
+ * Feeds the create-form seeding and the editors' null fallback alike, so
273
+ * a non-blank `emptyValue` also pre-fills the editor of a record whose
274
+ * stored value is null.
259
275
  */
260
276
  inputEmptyValue(): any {
261
- return null
277
+ return this.data.emptyValue != null
278
+ ? this.payloadToInput(this.data.emptyValue)
279
+ : null
262
280
  }
263
281
 
264
282
  /**
@@ -294,7 +312,7 @@ export abstract class Field<T extends FieldData> {
294
312
  */
295
313
  protected defineFormInputComponent(fn: (props: any, emit: any) => any): any {
296
314
  return defineComponent(fn, {
297
- props: ['modelValue', 'validation'],
315
+ props: ['modelValue', 'validation', 'size'],
298
316
  emits: ['update:modelValue']
299
317
  })
300
318
  }
@@ -49,14 +49,10 @@ export class FileUploadField extends Field<FileUploadFieldData> {
49
49
  })
50
50
  }
51
51
 
52
- override inputEmptyValue(): any {
53
- return []
54
- }
55
-
56
52
  override formInputComponent(): any {
57
53
  return this.defineFormInputComponent((props, { emit }) => {
58
54
  return () => h(SInputFileUpload, {
59
- 'size': 'mini',
55
+ 'size': props.size ?? 'mini',
60
56
  'label': this.formInputLabel(),
61
57
  'placeholder': this.placeholder() || undefined,
62
58
  'help': this.help() || undefined,
@@ -38,7 +38,7 @@ export class LinkField extends Field<LinkFieldData> {
38
38
  override formInputComponent(): any {
39
39
  return this.defineFormInputComponent((props, { emit }) => {
40
40
  return () => h(SInputText, {
41
- 'size': 'md',
41
+ 'size': props.size ?? 'md',
42
42
  'label': this.formInputLabel(),
43
43
  'placeholder': this.placeholder() || undefined,
44
44
  'help': this.help() || undefined,
@@ -150,10 +150,6 @@ export class SelectField extends Field<SelectFieldData> {
150
150
  return this.optionsForValues(value).map((o) => this.labelForOption(o)).join(', ')
151
151
  }
152
152
 
153
- override inputEmptyValue(): any {
154
- return this.data.multiple ? [] : null
155
- }
156
-
157
153
  override formInputComponent(): any {
158
154
  switch (this.data.inputAs) {
159
155
  case 'dropdown':
@@ -166,7 +162,7 @@ export class SelectField extends Field<SelectFieldData> {
166
162
  defineDropdownInputComponent(): any {
167
163
  return this.defineFormInputComponent((props, { emit }) => {
168
164
  return () => h(SInputDropdown, {
169
- 'size': 'md',
165
+ 'size': props.size ?? 'md',
170
166
  'label': this.formInputLabel(),
171
167
  'placeholder': this.placeholder() || undefined,
172
168
  'help': this.help() || undefined,
@@ -188,7 +184,7 @@ export class SelectField extends Field<SelectFieldData> {
188
184
  const Comp = this.data.multiple ? SInputCheckboxes : SInputRadios
189
185
 
190
186
  return () => h(Comp as any, {
191
- 'size': 'md',
187
+ 'size': props.size ?? 'md',
192
188
  'label': this.formInputLabel(),
193
189
  'help': this.help() || undefined,
194
190
  'options': this.data.options.map((o) => ({
@@ -34,7 +34,7 @@ export class TextField extends Field<TextFieldData> {
34
34
  override formInputComponent(): any {
35
35
  return this.defineFormInputComponent((props, { emit }) => {
36
36
  return () => h(SInputText, {
37
- 'size': 'md',
37
+ 'size': props.size ?? 'md',
38
38
  'label': this.formInputLabel(),
39
39
  'placeholder': this.placeholder() || undefined,
40
40
  'help': this.help() || undefined,
@@ -36,7 +36,7 @@ export class TextareaField extends Field<TextareaFieldData> {
36
36
  override formInputComponent(): any {
37
37
  return this.defineFormInputComponent((props, { emit }) => {
38
38
  return () => h(SInputTextarea, {
39
- 'size': 'md',
39
+ 'size': props.size ?? 'md',
40
40
  'label': this.formInputLabel(),
41
41
  'placeholder': this.placeholder() || undefined,
42
42
  'help': this.help() || undefined,
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { type Component, type MaybeRef, computed, unref, useSlots } from 'vue'
3
3
  import { type Position } from '../composables/Tooltip'
4
+ import { useHasSlotContent } from '../composables/Utils'
4
5
  import { type ColorMode } from '../support/Color'
5
6
  import SFragment from './SFragment.vue'
6
7
  import SLink from './SLink.vue'
@@ -60,11 +61,15 @@ const emit = defineEmits<{
60
61
 
61
62
  const _leadIcon = computed(() => props.leadIcon ?? props.icon)
62
63
 
64
+ // Rendered content, not slot presence: a slot that renders empty falls back
65
+ // to the `label` prop (and an icon-only button keeps its icon-only padding).
66
+ const hasDefaultSlot = useHasSlotContent()
67
+
63
68
  const classes = computed(() => [
64
69
  props.size ?? 'medium',
65
70
  props.type ?? 'fill',
66
71
  props.mode ?? 'default',
67
- { 'has-label': !!props.label },
72
+ { 'has-label': !!props.label || hasDefaultSlot.value },
68
73
  { 'has-lead-icon': !!_leadIcon.value },
69
74
  { 'has-trail-icon': !!props.trailIcon },
70
75
  { loading: props.loading },
@@ -118,7 +123,8 @@ function onClick(): void {
118
123
  <span v-if="_leadIcon" class="icon" :class="iconMode">
119
124
  <component :is="_leadIcon" class="icon-svg" />
120
125
  </span>
121
- <span v-if="label" class="label" :class="labelMode" v-html="label" />
126
+ <span v-if="hasDefaultSlot" class="label" :class="labelMode"><slot /></span>
127
+ <span v-else-if="label" class="label" :class="labelMode" v-html="label" />
122
128
  <span v-if="trailIcon" class="icon" :class="iconMode">
123
129
  <component :is="trailIcon" class="icon-svg" />
124
130
  </span>
@@ -9,11 +9,16 @@ const props = withDefaults(defineProps<{
9
9
  preWrap?: boolean
10
10
  lineClamp?: string | number
11
11
  tnum?: boolean
12
+ valueAction?: boolean
12
13
  }>(), {
13
14
  dir: 'row',
14
15
  maxWidth: '100%'
15
16
  })
16
17
 
18
+ const emit = defineEmits<{
19
+ 'click:value': [event: MouseEvent]
20
+ }>()
21
+
17
22
  const { labelWidth } = useDataListState()
18
23
 
19
24
  const slots = useSlots()
@@ -48,6 +53,45 @@ function hasSlotContent(name = 'default'): boolean {
48
53
  return true
49
54
  })
50
55
  }
56
+
57
+ function onValueClick(event: MouseEvent): void {
58
+ if (!props.valueAction || isInteractiveClick(event)) {
59
+ return
60
+ }
61
+
62
+ emit('click:value', event)
63
+ }
64
+
65
+ // Whether the click landed on interactive content (a link, button, input, …)
66
+ // inside the cell, which handles the click itself. The `closest()` match is
67
+ // bounded to the cell (`currentTarget`): an interactive *ancestor* of the
68
+ // whole list — a tabindexed scroll container, say — must not swallow every
69
+ // cell click.
70
+ function isInteractiveClick(event: MouseEvent): boolean {
71
+ const cell = event.currentTarget
72
+ const target = event.target
73
+
74
+ if (!(cell instanceof HTMLElement) || !(target instanceof HTMLElement)) {
75
+ return false
76
+ }
77
+
78
+ const interactive = target.closest([
79
+ 'a',
80
+ 'button',
81
+ 'input',
82
+ 'textarea',
83
+ 'select',
84
+ '[contenteditable="true"]',
85
+ '[role="button"]',
86
+ '[role="link"]',
87
+ '[role="checkbox"]',
88
+ '[role="radio"]',
89
+ '[role="switch"]',
90
+ '[tabindex]:not([tabindex="-1"])'
91
+ ].join(','))
92
+
93
+ return !!interactive && cell.contains(interactive)
94
+ }
51
95
  </script>
52
96
 
53
97
  <template>
@@ -56,10 +100,10 @@ function hasSlotContent(name = 'default'): boolean {
56
100
  <div class="label" :style="labelStyles">
57
101
  <slot name="label" />
58
102
  </div>
59
- <div v-if="!hasValue" class="empty">
103
+ <div v-if="!hasValue" class="empty" :class="{ action: valueAction }" @click="onValueClick">
60
104
 
61
105
  </div>
62
- <div v-else-if="hasValue" class="value" :style="valueStyles">
106
+ <div v-else-if="hasValue" class="value" :class="{ action: valueAction }" :style="valueStyles" @click="onValueClick">
63
107
  <slot name="value" />
64
108
  </div>
65
109
  </div>
@@ -102,6 +146,11 @@ function hasSlotContent(name = 'default'): boolean {
102
146
  color: var(--c-text-1);
103
147
  }
104
148
 
149
+ .empty.action,
150
+ .value.action {
151
+ cursor: pointer;
152
+ }
153
+
105
154
  .SDataListItem.row .content {
106
155
  flex-direction: row;
107
156
  }
@@ -7,6 +7,7 @@ import {
7
7
  type DropdownSectionFilterSelectedValue
8
8
  } from '../composables/Dropdown'
9
9
  import { useTrans } from '../composables/Lang'
10
+ import { stopNonSubmitEnterKeydown } from '../support/Dom'
10
11
  import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
11
12
 
12
13
  const props = defineProps<{
@@ -28,6 +29,7 @@ const { t } = useTrans({
28
29
  })
29
30
 
30
31
  const input = ref<HTMLElement | null>(null)
32
+ const list = ref<HTMLUListElement | null>(null)
31
33
  const query = ref('')
32
34
 
33
35
  const enabledOptions = computed(() => {
@@ -53,8 +55,16 @@ function isActive(value: any) {
53
55
  return Array.isArray(selected) ? selected.includes(value) : selected === value
54
56
  }
55
57
 
58
+ // Move focus from the search input into the first option (ArrowDown from the
59
+ // search field).
60
+ function focusFirstOption() {
61
+ list.value?.querySelector<HTMLElement>('.button')?.focus()
62
+ }
63
+
56
64
  function focusPrev(event: any) {
57
- event.target.parentNode.previousElementSibling?.firstElementChild?.focus()
65
+ // ArrowUp from the first option returns focus to the search input (if any).
66
+ const prev = event.target.parentNode.previousElementSibling?.firstElementChild
67
+ prev ? prev.focus() : input.value?.focus()
58
68
  }
59
69
 
60
70
  function focusNext(event: any) {
@@ -70,12 +80,18 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
70
80
  <template>
71
81
  <div class="SDropdownSectionFilter">
72
82
  <div v-if="search" class="search">
73
- <!-- Keep Enter inside the filter: it drives the dropdown, and must not
74
- bubble to an enclosing editor/form as a submit. -->
75
- <input ref="input" v-model="query" class="input" :placeholder="t.i_ph" @keydown.enter.stop>
83
+ <input
84
+ ref="input"
85
+ v-model="query"
86
+ class="input"
87
+ :placeholder="t.i_ph"
88
+ @keydown.enter="stopNonSubmitEnterKeydown"
89
+ @keydown.down.prevent
90
+ @keyup.down.prevent="focusFirstOption"
91
+ >
76
92
  </div>
77
93
 
78
- <ul v-if="filteredOptions.length" class="list">
94
+ <ul v-if="filteredOptions.length" ref="list" class="list">
79
95
  <li v-for="option in filteredOptions" :key="option.label" class="item">
80
96
  <button
81
97
  class="button"
@@ -154,6 +170,13 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
154
170
  &:hover {
155
171
  background-color: var(--c-bg-mute-1);
156
172
  }
173
+
174
+ /* The global reset strips `button:focus` outlines, so keyboard focus
175
+ (arrowing through the options) needs its own visible ring. */
176
+ &:focus-visible {
177
+ outline: 2px solid var(--input-focus-border-color);
178
+ outline-offset: -2px;
179
+ }
157
180
  }
158
181
 
159
182
  .checkbox {
@@ -6,6 +6,7 @@ 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 { stopNonSubmitEnterKeydown } from '../support/Dom'
9
10
  import { type Option } from '../support/InputDropdown'
10
11
  import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
11
12
  import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
@@ -76,6 +77,7 @@ const { t } = useTrans({
76
77
  })
77
78
 
78
79
  const container = ref<HTMLDivElement>()
80
+ const box = ref<HTMLDivElement | null>(null)
79
81
  const input = ref<HTMLInputElement | null>(null)
80
82
  const list = ref<HTMLUListElement | null>(null)
81
83
 
@@ -209,6 +211,44 @@ function onOpen() {
209
211
  nextTick(() => input.value?.focus())
210
212
  }
211
213
 
214
+ // Clicking (or Enter on — see onEnterToggle) the box toggles the flyout —
215
+ // closing returns focus to the box so keyboard interaction continues from the
216
+ // control instead of falling to `document.body`. ArrowDown stays open-only.
217
+ function onToggle() {
218
+ if (isOpen.value) {
219
+ close()
220
+ box.value?.focus()
221
+ return
222
+ }
223
+ onOpen()
224
+ }
225
+
226
+ // Bare Enter toggles on keydown, not keyup: a Cmd/Ctrl+Enter submit (handled
227
+ // on keydown by an enclosing editor) sheds its modifier before the Enter keyup
228
+ // when the modifier is released first, and that keyup must not reopen the
229
+ // flyout — acting on keydown sidesteps release order entirely. Key repeat is
230
+ // ignored so holding Enter doesn't flap the flyout.
231
+ function onEnterToggle(event: KeyboardEvent) {
232
+ if (!event.repeat) {
233
+ onToggle()
234
+ }
235
+ }
236
+
237
+ // Escape while the flyout is open belongs to the flyout: close it and return
238
+ // focus to the box, keeping the keydown from an enclosing surface (an inline
239
+ // editor would cancel, a sheet would close). A closed dropdown leaves Escape
240
+ // to those surfaces. The Escape that cancels an IME composition never
241
+ // operates the flyout.
242
+ function onEscapeKeydown(event: KeyboardEvent) {
243
+ if (!isOpen.value || event.isComposing) {
244
+ return
245
+ }
246
+ event.stopPropagation()
247
+ event.preventDefault()
248
+ close()
249
+ box.value?.focus()
250
+ }
251
+
212
252
  watch(isOpen, (value) => {
213
253
  // On close: drop any pending refetch, invalidate any in-flight fetch (so a late
214
254
  // response can't repopulate the list while closed), and clear the query /
@@ -264,8 +304,12 @@ function onSelect(item: T): void {
264
304
  commit(null)
265
305
  }
266
306
 
307
+ // Return focus to the box on a selection that closes the flyout, so keyboard
308
+ // interaction (reopening, an editor's submit shortcut) continues from the
309
+ // control rather than falling to `document.body`.
267
310
  if (props.closeOnSelect ?? !props.multiple) {
268
311
  close()
312
+ box.value?.focus()
269
313
  }
270
314
  }
271
315
 
@@ -330,14 +374,15 @@ function focusNext(event: any): void {
330
374
  :hide-error
331
375
  :hide-warning
332
376
  >
333
- <div ref="container" class="container">
377
+ <div ref="container" class="container" @keydown.esc="onEscapeKeydown">
334
378
  <div
379
+ ref="box"
335
380
  class="box"
336
381
  role="button"
337
382
  tabindex="0"
338
- @click="onOpen"
383
+ @click="onToggle"
339
384
  @keydown.down.prevent
340
- @keyup.enter="onOpen"
385
+ @keydown.enter.exact="onEnterToggle"
341
386
  @keyup.down="onOpen"
342
387
  >
343
388
  <div class="box-content">
@@ -362,16 +407,12 @@ function focusNext(event: any): void {
362
407
  <div v-if="isOpen" class="dropdown" :style="inset">
363
408
  <div class="dropdown-content">
364
409
  <div class="search">
365
- <!-- Keep Enter inside the search field: it drives the dropdown and
366
- must neither bubble to an enclosing form (`.stop`) nor trigger the
367
- browser's implicit form submission (`.prevent`). ArrowDown moves
368
- focus into the option list. -->
369
410
  <input
370
411
  ref="input"
371
412
  v-model="query"
372
413
  class="search-input"
373
414
  :placeholder="t.ph"
374
- @keydown.enter.stop.prevent
415
+ @keydown.enter="stopNonSubmitEnterKeydown"
375
416
  @keydown.down.prevent
376
417
  @keyup.down.prevent="focusFirstOption"
377
418
  >
@@ -442,6 +483,13 @@ function focusNext(event: any): void {
442
483
  &:hover {
443
484
  border-color: var(--input-hover-border-color);
444
485
  }
486
+
487
+ /* Keyboard focus shows via the border (the input-family idiom), not the
488
+ UA's default ring. */
489
+ &:focus-visible {
490
+ outline: none;
491
+ border-color: var(--input-focus-border-color);
492
+ }
445
493
  }
446
494
 
447
495
  .box-content {
@@ -544,6 +592,13 @@ function focusNext(event: any): void {
544
592
  background-color: var(--c-bg-mute-1);
545
593
  }
546
594
 
595
+ /* The global reset strips `button:focus` outlines, so keyboard focus
596
+ (arrowing through the options) needs its own visible ring. */
597
+ &:focus-visible {
598
+ outline: 2px solid var(--input-focus-border-color);
599
+ outline-offset: -2px;
600
+ }
601
+
547
602
  &:disabled {
548
603
  cursor: not-allowed;
549
604
  opacity: 0.5;
@@ -38,6 +38,7 @@ const { t } = useTrans({
38
38
  })
39
39
 
40
40
  const container = ref<HTMLDivElement>()
41
+ const box = ref<HTMLDivElement>()
41
42
 
42
43
  const { isOpen, open, close } = useFlyout(container)
43
44
  const { inset, update: updatePosition } = useManualDropdownPosition(
@@ -85,6 +86,44 @@ async function onOpen() {
85
86
  }
86
87
  }
87
88
 
89
+ // Clicking (or Enter on — see onEnterToggle) the box toggles the flyout —
90
+ // closing returns focus to the box so keyboard interaction continues from the
91
+ // control instead of falling to `document.body`. ArrowDown stays open-only.
92
+ function onToggle() {
93
+ if (isOpen.value) {
94
+ close()
95
+ box.value?.focus()
96
+ return
97
+ }
98
+ onOpen()
99
+ }
100
+
101
+ // Bare Enter toggles on keydown, not keyup: a Cmd/Ctrl+Enter submit (handled
102
+ // on keydown by an enclosing editor) sheds its modifier before the Enter keyup
103
+ // when the modifier is released first, and that keyup must not reopen the
104
+ // flyout — acting on keydown sidesteps release order entirely. Key repeat is
105
+ // ignored so holding Enter doesn't flap the flyout.
106
+ function onEnterToggle(event: KeyboardEvent) {
107
+ if (!event.repeat) {
108
+ onToggle()
109
+ }
110
+ }
111
+
112
+ // Escape while the flyout is open belongs to the flyout: close it and return
113
+ // focus to the box, keeping the keydown from an enclosing surface (an inline
114
+ // editor would cancel, a sheet would close). A closed dropdown leaves Escape
115
+ // to those surfaces. The Escape that cancels an IME composition never
116
+ // operates the flyout.
117
+ function onEscapeKeydown(event: KeyboardEvent) {
118
+ if (!isOpen.value || event.isComposing) {
119
+ return
120
+ }
121
+ event.stopPropagation()
122
+ event.preventDefault()
123
+ close()
124
+ box.value?.focus()
125
+ }
126
+
88
127
  function onSelect(value: T) {
89
128
  props.validation?.$touch()
90
129
 
@@ -99,7 +138,13 @@ function onSelect(value: T) {
99
138
  model.value = null
100
139
  }
101
140
 
102
- props.closeOnClick && close()
141
+ // Return focus to the box on a selection that closes the flyout, so
142
+ // keyboard interaction (reopening, an editor's submit shortcut) continues
143
+ // from the control rather than falling to `document.body`.
144
+ if (props.closeOnClick) {
145
+ close()
146
+ box.value?.focus()
147
+ }
103
148
  }
104
149
  </script>
105
150
 
@@ -121,14 +166,15 @@ function onSelect(value: T) {
121
166
  :hide-error
122
167
  :hide-warning
123
168
  >
124
- <div ref="container" class="container">
169
+ <div ref="container" class="container" @keydown.esc="onEscapeKeydown">
125
170
  <div
171
+ ref="box"
126
172
  class="box"
127
173
  role="button"
128
174
  tabindex="0"
129
- @click="onOpen"
175
+ @click="onToggle"
130
176
  @keydown.down.prevent
131
- @keyup.enter="onOpen"
177
+ @keydown.enter.exact="onEnterToggle"
132
178
  @keyup.down="onOpen"
133
179
  >
134
180
  <div class="box-content">
@@ -182,6 +228,13 @@ function onSelect(value: T) {
182
228
  &:hover {
183
229
  border-color: var(--input-hover-border-color);
184
230
  }
231
+
232
+ /* Keyboard focus shows via the border (the input-family idiom), not the
233
+ UA's default ring. */
234
+ &:focus-visible {
235
+ outline: none;
236
+ border-color: var(--input-focus-border-color);
237
+ }
185
238
  }
186
239
 
187
240
  .box-content {
@@ -1,8 +1,13 @@
1
1
  import {
2
+ Comment,
2
3
  type ComputedRef,
4
+ Fragment,
3
5
  type MaybeRefOrGetter,
6
+ Text,
7
+ type VNode,
4
8
  computed,
5
9
  getCurrentInstance,
10
+ isVNode,
6
11
  onMounted,
7
12
  toValue,
8
13
  useSlots
@@ -52,17 +57,52 @@ export function computedArrayWhen<T = any, C = any>(
52
57
  }, [])
53
58
  }
54
59
 
55
- /**
56
- * Checks whether the slot has a non empty value.
57
- */
60
+ function hasSlotContent(value: unknown): boolean {
61
+ if (value == null || typeof value === 'boolean') {
62
+ return false
63
+ }
64
+
65
+ if (typeof value === 'string') {
66
+ return value.trim().length > 0
67
+ }
68
+
69
+ if (typeof value === 'number' || typeof value === 'bigint') {
70
+ return true
71
+ }
72
+
73
+ if (Array.isArray(value)) {
74
+ return value.some(hasSlotContent)
75
+ }
76
+
77
+ if (!isVNode(value)) {
78
+ return false
79
+ }
80
+
81
+ const vnode = value as VNode
82
+
83
+ if (vnode.type === Comment) {
84
+ return false
85
+ }
86
+
87
+ if (vnode.type === Text || vnode.type === Fragment) {
88
+ return hasSlotContent(vnode.children)
89
+ }
90
+
91
+ // An element with only text children is judged by that text —
92
+ // `<div>{{ maybeEmpty }}</div>` must not defeat SDesc's empty fallback.
93
+ // Anything else is content in itself: a component, or an element whose
94
+ // `children` is `null` (an icon, an `<img>`, …), an array, or a slots object.
95
+ if (typeof vnode.children === 'string') {
96
+ return hasSlotContent(vnode.children)
97
+ }
98
+
99
+ return true
100
+ }
101
+
58
102
  export function useHasSlotContent(name = 'default'): ComputedRef<boolean> {
59
103
  const slots = useSlots()
60
104
 
61
- return computed(() => {
62
- return !!slots[name]?.().some((s) => {
63
- return Array.isArray(s.children) ? true : !!(s.children as string).trim()
64
- })
65
- })
105
+ return computed(() => hasSlotContent(slots[name]?.()))
66
106
  }
67
107
 
68
108
  /**