@redseed/redseed-ui-vue3 8.21.0 → 8.21.2

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": "8.21.0",
3
+ "version": "8.21.2",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue'
2
+ import { ref, computed, watch, onMounted, onUnmounted, useAttrs } from 'vue'
3
3
  import { onClickOutside, useElementBounding } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
@@ -55,6 +55,34 @@ function nativeChoose(event) {
55
55
  emit('change', event.target.value)
56
56
  }
57
57
 
58
+ /**
59
+ * Track native constraint-validation state for the desktop trigger.
60
+ *
61
+ * On desktop the native <select> is sr-only, so when the browser fires its
62
+ * `invalid` event (e.g. a required field left empty on form submit) the
63
+ * tooltip and focus would target the off-screen element. We intercept that
64
+ * event, prevent the default browser behaviour, and instead focus the
65
+ * visible <button> trigger while applying :invalid-equivalent styling
66
+ * through this reactive flag and displaying the validation message text.
67
+ */
68
+ const isNativeInvalid = ref(false)
69
+ const nativeValidationMessage = ref('')
70
+
71
+ function handleInvalid(event) {
72
+ if (isMobileDevice.value) return
73
+
74
+ event.preventDefault()
75
+ isNativeInvalid.value = true
76
+ nativeValidationMessage.value = event.target.validationMessage || 'Please select an option.'
77
+ triggerElement.value?.focus()
78
+ }
79
+
80
+ // Clear the invalid state as soon as the user selects a value
81
+ watch(model, () => {
82
+ isNativeInvalid.value = false
83
+ nativeValidationMessage.value = ''
84
+ })
85
+
58
86
  function handleKeydown(event) {
59
87
  if (!isOpen.value && (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ')) {
60
88
  if (event.repeat) return
@@ -134,9 +162,29 @@ function calculateDropdownPosition() {
134
162
  */
135
163
  const spaceBelow = viewportHeight - anchorBounding.bottom.value
136
164
 
137
- dropdownElement.value.style.width = `${anchorBounding.width.value}px`
165
+ dropdownElement.value.style.minWidth = `${anchorBounding.width.value}px`
138
166
 
167
+ /**
168
+ * Clamp the dropdown to the viewport horizontally.
169
+ * First cap maxWidth to the full viewport minus margins, then shift
170
+ * left if the dropdown's natural width would overflow the right edge.
171
+ * This keeps the popover fully visible for triggers near the right
172
+ * edge (e.g. fixed-width filter selects in ListControl).
173
+ */
174
+ const viewportWidth = window.innerWidth
175
+ const safeMargin = 16
176
+ dropdownElement.value.style.maxWidth = `${viewportWidth - safeMargin * 2}px`
177
+
178
+ // Temporarily position at trigger left so we can measure actual width
139
179
  dropdownElement.value.style.left = `${anchorBounding.left.value}px`
180
+ const dropdownWidth = dropdownElement.value.offsetWidth
181
+ const rightOverflow = (anchorBounding.left.value + dropdownWidth + safeMargin) - viewportWidth
182
+
183
+ // Shift left if the dropdown overflows the right edge
184
+ if (rightOverflow > 0) {
185
+ const clampedLeft = Math.max(safeMargin, anchorBounding.left.value - rightOverflow)
186
+ dropdownElement.value.style.left = `${clampedLeft}px`
187
+ }
140
188
 
141
189
  if (spaceAbove <= dropdownElementHeight
142
190
  && spaceBelow <= dropdownElementHeight) {
@@ -170,6 +218,10 @@ onUnmounted(() => {
170
218
 
171
219
  defineExpose({
172
220
  focus() {
221
+ if (!isMobileDevice.value && triggerElement.value) {
222
+ triggerElement.value.focus()
223
+ return
224
+ }
173
225
  if (selectElement.value) selectElement.value.focus()
174
226
  },
175
227
  })
@@ -196,14 +248,18 @@ defineExpose({
196
248
  ref="triggerElement"
197
249
  v-if="!isMobileDevice"
198
250
  type="button"
199
- class="rsui-form-field-select__trigger peer"
251
+ :class="[
252
+ 'rsui-form-field-select__trigger',
253
+ 'peer',
254
+ { 'rsui-form-field-select__trigger--invalid': isNativeInvalid }
255
+ ]"
200
256
  role="combobox"
201
257
  :aria-activedescendant="highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
202
258
  :aria-controls="`${effectiveId}-listbox`"
203
259
  :aria-describedby="ariaDescribedby"
204
260
  :aria-expanded="isOpen"
205
261
  :aria-haspopup="'listbox'"
206
- :aria-invalid="ariaInvalid"
262
+ :aria-invalid="ariaInvalid || isNativeInvalid || undefined"
207
263
  :aria-required="$attrs.required || undefined"
208
264
  :disabled="$attrs.disabled"
209
265
  :id="effectiveId"
@@ -214,7 +270,7 @@ defineExpose({
214
270
  </button>
215
271
 
216
272
  <select ref="selectElement"
217
- :class="['peer', { 'sr-only': !isMobileDevice }]"
273
+ class="peer"
218
274
  v-model="model"
219
275
  :aria-describedby="isMobileDevice ? ariaDescribedby : undefined"
220
276
  :aria-invalid="isMobileDevice ? ariaInvalid : undefined"
@@ -228,6 +284,7 @@ defineExpose({
228
284
  :name="$attrs.name"
229
285
  :required="$attrs.required"
230
286
  @change.prevent="nativeChoose"
287
+ @invalid="handleInvalid"
231
288
  >
232
289
  <option value="" disabled>
233
290
  <slot name="default-option">
@@ -309,8 +366,10 @@ defineExpose({
309
366
  <slot name="help"></slot>
310
367
  </template>
311
368
 
312
- <template #error v-if="$slots.error">
313
- <slot name="error"></slot>
369
+ <template #error v-if="$slots.error || nativeValidationMessage">
370
+ <slot name="error">
371
+ {{ nativeValidationMessage }}
372
+ </slot>
314
373
  </template>
315
374
  </FormFieldSlot>
316
375
  </template>