@redseed/redseed-ui-vue3 9.0.0 → 10.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
- import { computed, inject, ref, toRefs, useAttrs } from 'vue'
2
+ import { computed, inject, ref, toRefs, useAttrs, useSlots } from 'vue'
3
3
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
4
4
  import Icon from '../Icon/Icon.vue'
5
+ import { rendersContent } from '../../helpers/slots'
5
6
  import { useResponsiveWidth } from '../../helpers'
6
7
  import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
7
8
 
@@ -28,9 +29,27 @@ const props = defineProps({
28
29
  type: Boolean,
29
30
  default: true,
30
31
  },
32
+ /**
33
+ * Force the overflow menu on, or suppress it.
34
+ *
35
+ * Left unset, the menu renders when the `more-actions` slot renders something
36
+ * — so a menu whose only option is behind a falsy `v-if` shows no trigger,
37
+ * rather than a three-dot button that opens on nothing. That was the bug: the
38
+ * gate tested the PROP, not what the slot produced.
39
+ *
40
+ * `true` forces it on, which is how a consumer asks for the built-in
41
+ * three-dot button with no slot of their own. `false` suppresses it even with
42
+ * a populated slot.
43
+ *
44
+ * Null rather than false as the default, and the gate is OR rather than AND,
45
+ * so every existing call site behaves identically: the ~38 that pass `false`
46
+ * still suppress, and the ones passing a slot with no prop — 3 in the LMS,
47
+ * plus this library's own Table.vue — still render. That makes the flip safe
48
+ * to ship without coordinating a consumer release. See #345.
49
+ */
31
50
  showMoreActions: {
32
51
  type: Boolean,
33
- default: true,
52
+ default: null,
34
53
  },
35
54
  // Default false: Material 3 cards are one continuous padded surface, and a
36
55
  // full-bleed rule between the header and the body made every card read as two
@@ -71,6 +90,22 @@ const { showDivider, headerOnlyCard } = toRefs(props)
71
90
 
72
91
  const attrs = useAttrs()
73
92
 
93
+ const slots = useSlots()
94
+
95
+ /**
96
+ * Whether the overflow region renders. The prop wins when set; otherwise the slot
97
+ * decides by whether it actually produces anything.
98
+ *
99
+ * A plain function rather than a computed: `useSlots()` is not reactive, so a
100
+ * computed over it caches its first answer for the life of the component and a
101
+ * conditionally-supplied menu would never appear. See helpers/slots.
102
+ */
103
+ function showsMoreActions() {
104
+ if (props.showMoreActions !== null) return props.showMoreActions
105
+
106
+ return rendersContent(slots['more-actions'], { handleMoreActionsClick })
107
+ }
108
+
74
109
  const isClickable = computed(() => !!attrs.onClick)
75
110
 
76
111
  const cardHeaderElement = ref(null)
@@ -183,7 +218,7 @@ function handleMoreActionsClick() {
183
218
  </div>
184
219
 
185
220
  <!-- More actions slot, optional -->
186
- <div v-if="showMoreActions"
221
+ <div v-if="showsMoreActions()"
187
222
  class="rsui-card-header__more-actions"
188
223
  >
189
224
  <slot name="more-actions"
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, watch, watchEffect, nextTick, onMounted } from 'vue'
2
+ import { ref, watch, watchEffect, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { ChevronDownIcon } from '@heroicons/vue/24/outline'
4
4
  import { ButtonTertiary } from '../Button'
5
5
  import Icon from '../Icon/Icon.vue'
@@ -87,28 +87,43 @@ watch(isOpen, (open, wasOpen) => {
87
87
  }
88
88
  }, { flush: 'sync' })
89
89
 
90
+ /**
91
+ * Whether each teleport target currently exists.
92
+ *
93
+ * Re-checked rather than latched. These used to only ever flip true, and the
94
+ * observer disconnected once both had been found — so a consumer that removed a
95
+ * target left a Teleport mounted against nothing and the trigger vanished.
96
+ * `SectionHeader` does exactly that: it width-gates the slots a target is
97
+ * mounted into, so narrowing a header past 640px took the trigger away and the
98
+ * section could no longer be opened. See #372.
99
+ */
90
100
  const canTeleportTrigger = ref(false)
91
101
  const canTeleportContent = ref(false)
92
102
 
103
+ let observer = null
104
+
93
105
  function setTeleport() {
94
- if (document.getElementById(triggerId)) canTeleportTrigger.value = true
95
- if (document.getElementById(contentId)) canTeleportContent.value = true
106
+ canTeleportTrigger.value = Boolean(document.getElementById(triggerId))
107
+ canTeleportContent.value = Boolean(document.getElementById(contentId))
96
108
  }
97
109
 
98
110
  onMounted(() => {
99
111
  setTeleport()
100
112
 
101
- const observer = new MutationObserver(() => {
102
- setTeleport()
103
-
104
- if (canTeleportTrigger.value && canTeleportContent.value) observer.disconnect()
105
- })
113
+ // Never disconnected: a target can appear AND disappear over a component's
114
+ // life, so there is no point at which the answer is settled.
115
+ observer = new MutationObserver(setTeleport)
106
116
 
107
117
  observer.observe(document.body, {
108
118
  childList: true,
109
119
  subtree: true,
110
120
  })
111
121
  })
122
+
123
+ onBeforeUnmount(() => {
124
+ observer?.disconnect()
125
+ observer = null
126
+ })
112
127
  </script>
113
128
 
114
129
  <template>
@@ -121,8 +136,21 @@ onMounted(() => {
121
136
  ></slot>
122
137
  </div>
123
138
 
124
- <Teleport v-if="canTeleportTrigger"
125
- :to="`#${triggerId}`"
139
+ <!--
140
+ `disabled` rather than `v-if`, so the trigger renders in place when the
141
+ consumer supplies no target instead of not rendering at all.
142
+
143
+ There used to be no second branch: `<Teleport v-if="canTeleportTrigger">`
144
+ and nothing else, so a missing target meant no trigger ANYWHERE rather
145
+ than one in a less good place. `SectionHeader` width-gates the slots a
146
+ target is mounted into, so below 640px of its OWN width — container, not
147
+ viewport — a section simply could not be opened. See #372.
148
+
149
+ Vue's own `disabled` keeps this as one definition rendered in one of two
150
+ places, rather than two copies that can drift.
151
+ -->
152
+ <Teleport :to="`#${triggerId}`"
153
+ :disabled="!canTeleportTrigger"
126
154
  >
127
155
  <slot name="trigger"
128
156
  :handleTrigger="handleTrigger"
@@ -155,8 +183,9 @@ onMounted(() => {
155
183
  </slot>
156
184
  </Teleport>
157
185
 
158
- <Teleport v-if="canTeleportContent"
159
- :to="`#${contentId}`"
186
+ <!-- Same reasoning as the trigger above: in place rather than nowhere. -->
187
+ <Teleport :to="`#${contentId}`"
188
+ :disabled="!canTeleportContent"
160
189
  >
161
190
  <div ref="contentRef"
162
191
  :class="[
@@ -1,6 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed } from 'vue'
3
- import Modal from '../Modal/Modal.vue'
2
+ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
4
3
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
5
4
 
6
5
  const props = defineProps({
@@ -49,7 +48,65 @@ const props = defineProps({
49
48
  },
50
49
  })
51
50
 
52
- const showMetaModal = ref(false)
51
+ /**
52
+ * The meta cap, and why the count is measured from the DOM rather than declared.
53
+ *
54
+ * `#meta` is a slot, and every consumer fills it with a `v-for` over their own
55
+ * data — so the component cannot know how many facts there are without either a
56
+ * prop nobody would remember to keep in sync, or walking vnodes. The row itself
57
+ * is capped in CSS with `:nth-child`, which needs no count at all; this observer
58
+ * exists only to decide whether the TOGGLE is needed, and what number to put in
59
+ * it.
60
+ *
61
+ * A MutationObserver rather than a one-off count, because those lists change:
62
+ * a fact arrives with a fetch, or a filter removes one, and a toggle offering
63
+ * "+2 more" when there is one left is worse than no toggle.
64
+ */
65
+ const META_CAP = 4
66
+
67
+ const metaElement = ref(null)
68
+ const metaCount = ref(0)
69
+ const isMetaExpanded = ref(false)
70
+
71
+ const hiddenMetaCount = computed(() => Math.max(0, metaCount.value - META_CAP))
72
+
73
+ const metaToggleLabel = computed(() =>
74
+ isMetaExpanded.value ? 'Show fewer' : `+${hiddenMetaCount.value} more`
75
+ )
76
+
77
+ /**
78
+ * WCAG 2.5.3 Label in Name: the accessible name has to contain the visible text,
79
+ * or a speech-input user has nothing to say. Same shape as CategoryList's toggle.
80
+ */
81
+ const metaToggleAccessibleName = computed(() =>
82
+ isMetaExpanded.value
83
+ ? 'Show fewer details'
84
+ : `+${hiddenMetaCount.value} more details`
85
+ )
86
+
87
+ let metaObserver = null
88
+
89
+ function countMetaFacts() {
90
+ if (!metaElement.value) return
91
+
92
+ // Every child is a fact: the toggle lives outside this element precisely so
93
+ // neither this count nor `:nth-child` has to special-case it.
94
+ metaCount.value = metaElement.value.children.length
95
+ }
96
+
97
+ onMounted(() => {
98
+ countMetaFacts()
99
+
100
+ if (!metaElement.value) return
101
+
102
+ metaObserver = new MutationObserver(countMetaFacts)
103
+ metaObserver.observe(metaElement.value, { childList: true })
104
+ })
105
+
106
+ onBeforeUnmount(() => {
107
+ metaObserver?.disconnect()
108
+ metaObserver = null
109
+ })
53
110
 
54
111
  const pageHeaderClass = computed(() => [
55
112
  'rsui-page-header',
@@ -121,33 +178,6 @@ const pageHeaderClass = computed(() => [
121
178
 
122
179
 
123
180
  <div class="rsui-page-header__actions">
124
- <div v-if="$slots['meta-action-label'] && $slots['meta']" class="rsui-page-header__meta-action">
125
- <ButtonTertiary @click="showMetaModal = true">
126
- <slot name="meta-action-label"></slot>
127
- </ButtonTertiary>
128
-
129
- <Modal sm v-if="$slots['meta']" class="rsui-page-header__meta-modal" :show="showMetaModal"
130
- @close="showMetaModal = false">
131
- <template #header v-if="$slots['meta-modal-header']">
132
- <slot name="meta-modal-header"></slot>
133
- </template>
134
-
135
- <div v-if="$slots.meta" class="rsui-page-header__meta-modal__body">
136
- <slot name="meta"></slot>
137
- </div>
138
-
139
- <template #footer v-if="$slots['meta-modal-footer'] || $slots['meta-modal-close-label']">
140
- <div v-if="$slots['meta-modal-footer']">
141
- <slot name="meta-modal-footer"></slot>
142
- </div>
143
-
144
- <ButtonTertiary @click="showMetaModal = false">
145
- <slot name="meta-modal-close-label"></slot>
146
- </ButtonTertiary>
147
- </template>
148
- </Modal>
149
- </div>
150
-
151
181
  <slot name="actions"></slot>
152
182
  </div>
153
183
  </div>
@@ -170,8 +200,48 @@ const pageHeaderClass = computed(() => [
170
200
  <slot name="categories"></slot>
171
201
  </div>
172
202
 
173
- <div v-if="$slots['meta']" class="rsui-page-header__meta">
174
- <slot name="meta"></slot>
203
+ <!--
204
+ Meta stays at every width. It used to be `hidden lg:flex`, with a
205
+ "Details" button opening a modal below lg — so the facts a header
206
+ exists to show disappeared on a phone, which is where a reader has
207
+ least context to spare. See #369.
208
+
209
+ The cap is CSS rather than JS: `#meta` is a slot and its contents are
210
+ usually a `v-for`, so the component cannot count facts without
211
+ inspecting vnodes, and a count taken at setup would be wrong the
212
+ moment the list changed. `:nth-child` does not need to know.
213
+ -->
214
+ <div v-if="$slots['meta']" class="rsui-page-header__meta-region">
215
+ <!--
216
+ The facts and the toggle are siblings in the GRID but the toggle
217
+ is not one of the facts, and `:nth-child` cannot tell the
218
+ difference — put it inside the same element and it takes a
219
+ position, shifting every fact after it and hiding one too many.
220
+ Measured: with six facts the toggle landed fourth and only three
221
+ showed.
222
+
223
+ So the facts own this element alone, and the toggle sits after it
224
+ in a wrapper that participates in the same grid via
225
+ `display: contents`. The count is then honest at every size.
226
+ -->
227
+ <div :class="[
228
+ 'rsui-page-header__meta',
229
+ { 'rsui-page-header__meta--expanded': isMetaExpanded },
230
+ ]"
231
+ ref="metaElement"
232
+ >
233
+ <slot name="meta"></slot>
234
+ </div>
235
+
236
+ <button v-if="hiddenMetaCount > 0"
237
+ type="button"
238
+ class="rsui-page-header__meta-toggle"
239
+ :aria-expanded="isMetaExpanded"
240
+ :aria-label="metaToggleAccessibleName"
241
+ @click="isMetaExpanded = !isMetaExpanded"
242
+ >
243
+ {{ metaToggleLabel }}
244
+ </button>
175
245
  </div>
176
246
  </div>
177
247
  </template>
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, useAttrs, useSlots } from 'vue'
3
+ import { rendersContent } from '../../helpers/slots'
3
4
  import ButtonTertiary from '../Button/ButtonTertiary.vue'
4
5
  import Icon from '../Icon/Icon.vue'
5
6
  import { useResponsiveWidth } from '../../helpers'
@@ -18,9 +19,19 @@ const props = defineProps({
18
19
  type: Boolean,
19
20
  default: true,
20
21
  },
22
+ /**
23
+ * Force the overflow menu on, or suppress it. Left unset, the menu renders
24
+ * when the `more-actions` slot renders something — so a menu whose only
25
+ * option is behind a falsy `v-if` shows no trigger rather than a three-dot
26
+ * button that opens on nothing.
27
+ *
28
+ * Null default with an OR gate, so every existing call site is unaffected:
29
+ * the ones passing `false` still suppress, the ones passing a slot with no
30
+ * prop still render. Same shape as CardHeader. See #345.
31
+ */
21
32
  showMoreActions: {
22
33
  type: Boolean,
23
- default: true,
34
+ default: null,
24
35
  },
25
36
  // Default false. It was true, but no CSS rule existed behind the --divider class,
26
37
  // so every consumer has been seeing no rule regardless. The rule exists now; keeping
@@ -72,16 +83,57 @@ function handleMoreActionsClick() {
72
83
 
73
84
  const attrs = useAttrs()
74
85
 
86
+ /**
87
+ * A header with a click handler is a control, so it gets a control's semantics —
88
+ * a role, a tab stop and keyboard activation — rather than only a pointer cursor
89
+ * and a hover state.
90
+ *
91
+ * It responded to a click before this, but as a plain <div>: nothing announced
92
+ * it, nothing could reach it by keyboard, and the hover state was a promise it
93
+ * could not keep. See #338.
94
+ *
95
+ * This is also what takes the disclosure trigger out of the width-gated `#icon`
96
+ * slot: with the header itself as the target, a section no longer becomes
97
+ * unopenable below 640px of container width. See #372.
98
+ */
75
99
  const isClickable = computed(() => !!attrs.onClick)
76
100
 
101
+ /**
102
+ * Enter and Space, the two keys a button answers to. `.self` so a keypress inside
103
+ * the header's own controls — a more-actions menu, a toolbar button — does not
104
+ * also toggle the header, and a repeat guard so a held key fires once.
105
+ */
106
+ function handleKeydown(event) {
107
+ if (event.repeat) return
108
+
109
+ attrs.onClick?.(event)
110
+ }
111
+
77
112
  const slots = useSlots()
78
113
 
79
- const showToolbar = computed(() => {
80
- const showMoreActions = props.showMoreActions && slots['more-actions']
114
+ /**
115
+ * Whether the overflow region renders. The prop wins when set; otherwise the slot
116
+ * decides by whether it actually produces anything.
117
+ */
118
+ function showsMoreActions() {
119
+ if (props.showMoreActions !== null) return props.showMoreActions
81
120
 
82
- return props.showActions
83
- && (slots.actions || showMoreActions)
84
- })
121
+ return rendersContent(slots['more-actions'], { handleMoreActionsClick })
122
+ }
123
+
124
+ /**
125
+ * Whether the toolbar region renders at all.
126
+ *
127
+ * A plain function, not a computed. `useSlots()` returns a non-reactive object,
128
+ * so a computed over it cached its first answer for the life of the component —
129
+ * a consumer supplying `#actions` conditionally would never have seen the toolbar
130
+ * appear. That is the #332 defect, and this is the confirmed-broken instance of
131
+ * it: the desktop region is gated here while the mobile one reads `$slots.actions`
132
+ * inline, so the same header worked below 640px and not above it.
133
+ */
134
+ function showsToolbar() {
135
+ return props.showActions && (rendersContent(slots.actions) || showsMoreActions())
136
+ }
85
137
 
86
138
  /**
87
139
  * Element used to render the title — a real heading when a level is supplied,
@@ -98,6 +150,10 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
98
150
  'rsui-section-header--clickable': isClickable,
99
151
  }
100
152
  ]"
153
+ :role="isClickable ? 'button' : undefined"
154
+ :tabindex="isClickable ? 0 : undefined"
155
+ @keydown.enter.self.prevent="handleKeydown"
156
+ @keydown.space.self.prevent="handleKeydown"
101
157
  >
102
158
  <div class="rsui-section-header__header">
103
159
 
@@ -110,7 +166,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
110
166
 
111
167
  <div :class="{
112
168
  'rsui-section-header__text': true,
113
- 'rsui-section-header__text--with-toolbar': showToolbar || $slots.icon,
169
+ 'rsui-section-header__text--with-toolbar': showsToolbar() || $slots.icon,
114
170
  }">
115
171
 
116
172
  <!-- Title slot, default slot -->
@@ -141,7 +197,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
141
197
 
142
198
  <!-- Actions slot, optional -->
143
199
  <div class="rsui-section-header__toolbar"
144
- v-if="showToolbar"
200
+ v-if="showsToolbar()"
145
201
  >
146
202
  <!-- Desktop actions slot, optional -->
147
203
  <div class="rsui-section-header__actions-desktop"
@@ -151,7 +207,7 @@ const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` :
151
207
  </div>
152
208
 
153
209
  <!-- More actions slot, optional -->
154
- <div v-if="showMoreActions"
210
+ <div v-if="showsMoreActions()"
155
211
  class="rsui-section-header__more-actions"
156
212
  >
157
213
  <slot name="more-actions"
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
- import { ref, computed, watch, watchEffect, useSlots } from 'vue'
2
+ import { ref, computed, watch, watchEffect, useSlots, useAttrs } from 'vue'
3
3
  import { rendersContent } from '../../helpers/slots'
4
+ import { warnOnUnknownProps } from '../../helpers/unknownProps'
4
5
  import { useScroll, useEventListener, watchDebounced } from '@vueuse/core'
5
6
  import Section from './Section.vue'
6
7
  import SectionHeader from './SectionHeader.vue'
@@ -130,6 +131,14 @@ const sliderClasses = computed(() => [
130
131
 
131
132
  const slots = useSlots()
132
133
 
134
+ /**
135
+ * Both sliders in the product pass a `variant` prop this component does not have.
136
+ * Vue drops it into `$attrs`, it lands on the root as a bare DOM attribute, and
137
+ * nothing happens — so the consumer believes they configured something. Dev-only.
138
+ * See #339.
139
+ */
140
+ warnOnUnknownProps('SectionSlider', useAttrs())
141
+
133
142
  /**
134
143
  * A consumer-supplied `actions` slot always wins and always renders in the
135
144
  * header — that slot exists so consumers can put their own controls up there.
@@ -0,0 +1,72 @@
1
+ import { watchEffect } from 'vue'
2
+
3
+ /**
4
+ * Warn, in development, when a component is handed an attribute that looks like
5
+ * a prop it does not declare.
6
+ *
7
+ * Internal — not re-exported from `helpers/index.js`. It is a authoring guard for
8
+ * this library's own components, not something a consuming app should call.
9
+ *
10
+ * The case it catches: a consumer passes `variant="featured"` to a component with
11
+ * no `variant` prop. Vue puts it in `$attrs`, it lands on the root as a bare DOM
12
+ * attribute, and nothing happens — no effect, no error, nothing in the console.
13
+ * The consumer believes they have configured something. Both sliders in the
14
+ * product do exactly this (#339).
15
+ *
16
+ * Deliberately conservative about what counts as suspicious, because a warning
17
+ * that cries wolf is worse than none: anything a consumer legitimately passes
18
+ * through is ignored, and only a plain lowercase word with no dash is flagged.
19
+ * That is the shape of a prop name, and it is not the shape of `data-*`,
20
+ * `aria-*`, an event listener, or a standard global attribute.
21
+ */
22
+
23
+ /**
24
+ * Attributes any consumer may pass for their own reasons. Not exhaustive — it
25
+ * does not need to be, because the dash and `on*` rules below already exclude
26
+ * most of what is left.
27
+ */
28
+ const PASS_THROUGH = new Set([
29
+ 'id', 'class', 'style', 'title', 'role', 'tabindex', 'hidden', 'slot', 'part',
30
+ 'lang', 'dir', 'draggable', 'contenteditable', 'spellcheck', 'translate',
31
+ 'autofocus', 'inert', 'popover', 'key', 'ref',
32
+ ])
33
+
34
+ function looksLikeAProp(name) {
35
+ if (PASS_THROUGH.has(name)) return false
36
+
37
+ // `data-*`, `aria-*`, and any other namespaced or hyphenated attribute.
38
+ if (name.includes('-')) return false
39
+
40
+ // Event listeners arrive as onClick, onFooBar.
41
+ if (/^on[A-Z]/.test(name)) return false
42
+
43
+ // A prop name is a bare word, optionally camelCased.
44
+ return /^[a-z][a-zA-Z0-9]*$/.test(name)
45
+ }
46
+
47
+ /**
48
+ * @param {string} component Name used in the message, e.g. 'SectionSlider'.
49
+ * @param {object} attrs The component's `useAttrs()` object.
50
+ *
51
+ * Call from `setup()`. Dev-only and deferred by a tick, matching SectionFooter
52
+ * and Modal: this package ships raw `.vue` source, so an ungated warn reaches
53
+ * real users' consoles, and an attribute that is present for one tick during a
54
+ * transition should not be scolded.
55
+ */
56
+ export function warnOnUnknownProps(component, attrs) {
57
+ if (process.env.NODE_ENV === 'production') return
58
+
59
+ watchEffect((onCleanup) => {
60
+ const suspicious = Object.keys(attrs).filter(looksLikeAProp)
61
+
62
+ if (suspicious.length === 0) return
63
+
64
+ const timer = setTimeout(() => console.warn(
65
+ `[RSUI] ${component}: received ${suspicious.map(name => `\`${name}\``).join(', ')}, which ${suspicious.length === 1 ? 'is not a prop' : 'are not props'} of this component. `
66
+ + `${suspicious.length === 1 ? 'It has' : 'They have'} landed on the root element as a plain attribute and will do nothing. `
67
+ + 'Check the spelling, or remove it.',
68
+ ), 0)
69
+
70
+ onCleanup(() => clearTimeout(timer))
71
+ })
72
+ }