@bluerobotics/bluevue 0.2.1 → 0.2.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.
@@ -6,19 +6,28 @@
6
6
  <div
7
7
  v-if="label"
8
8
  class="min-w-0 max-w-[45%] shrink-0"
9
+ :style="labelWidth ? { minWidth: labelWidth } : undefined"
9
10
  >
10
11
  <label
12
+ :for="sliderId"
11
13
  class="block truncate text-start mr-6"
12
14
  :title="label"
13
15
  :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
14
16
  >{{ label }}</label>
15
17
  </div>
16
- <div class="flex w-1/2 min-w-0 items-center justify-between">
18
+ <!-- Half the row, so a column of sliders reads as one block against the panel's midline, until
19
+ half of it is less than a track can be aimed at. Whichever is larger then takes over and
20
+ the track runs to the right edge, and past even that floor it shrinks rather than
21
+ overflowing, down to the 140px below. -->
22
+ <div class="flex basis-[max(50%,320px)] min-w-[140px] items-center justify-start">
23
+ <!-- isolate keeps the pill and the min/max labels stacked against the track alone: without
24
+ it their z-index is resolved against the page, and they paint over whatever chrome the
25
+ host has floating above the row. -->
17
26
  <div
18
27
  name="slider-track"
19
- class="relative overflow-visible rounded-[6px] bluevue-elevation-1 min-w-[140px] max-w-full"
28
+ class="relative isolate w-full overflow-visible rounded-[6px] bluevue-elevation-1 min-w-[140px]"
20
29
  :class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30' : '']"
21
- :style="{ width: width || '100%', height: height || '30px', cursor: disabled ? 'not-allowed' : 'pointer' }"
30
+ :style="{ maxWidth: width || '100%', height: height || '30px', cursor: disabled ? 'not-allowed' : 'pointer' }"
22
31
  >
23
32
  <div class="absolute inset-x-[18%] top-1/2 -translate-y-1/2 flex justify-between pointer-events-none">
24
33
  <div
@@ -42,7 +51,8 @@
42
51
  >
43
52
  <div v-if="!isEditingCurrentSliderValue">
44
53
  <p
45
- class="font-bold leading-none select-none"
54
+ class="leading-none select-none"
55
+ :class="valueWeight === 'regular' ? 'font-normal' : 'font-bold'"
46
56
  draggable="false"
47
57
  >
48
58
  {{ formatDisplay ? formatDisplay(scaledValue) : scaledValue.toFixed(defaultDecimals) }}
@@ -53,6 +63,7 @@
53
63
  ref="editInput"
54
64
  v-model.number="editedDisplayValue"
55
65
  type="number"
66
+ :aria-label="label"
56
67
  :min="displayMin"
57
68
  :max="displayMax"
58
69
  :step="displayStep"
@@ -60,11 +71,12 @@
60
71
  class="bg-white border border-gray-300 rounded px-1 py-0.5"
61
72
  @input="clampEditedValue"
62
73
  @keydown="handleValueChange"
63
- @blur="isEditingCurrentSliderValue = false"
74
+ @blur="onEditBlur"
64
75
  >
65
76
  </div>
66
77
  </div>
67
78
  <input
79
+ :id="sliderId"
68
80
  v-model.number="currentSliderValue"
69
81
  type="range"
70
82
  class="absolute inset-0 w-full h-full opacity-0"
@@ -107,6 +119,8 @@
107
119
  <script setup lang="ts">
108
120
  import { ref, watch, onBeforeUnmount, computed } from 'vue'
109
121
 
122
+ import { nextElementId } from '../utils/id'
123
+
110
124
  const props = defineProps<{
111
125
  /** Pill color override. */
112
126
  color?: string
@@ -116,6 +130,8 @@ const props = defineProps<{
116
130
  height?: string
117
131
  /** Main Label text on the left. */
118
132
  label?: string
133
+ /** Floor for the label column, so a column of sliders starts its tracks at one place. */
134
+ labelWidth?: string
119
135
  /** Custom text for the max label. */
120
136
  labelMax?: string
121
137
  /** Custom text for the min label. */
@@ -132,7 +148,9 @@ const props = defineProps<{
132
148
  keyboardStepMultiplierLimit?: number
133
149
  /** 'light' or 'dark' theme. (default 'light')*/
134
150
  theme?: 'light' | 'dark' | 'transparent'
135
- /** Container width (default '100%'). */
151
+ /** Weight of the value in the pill (default 'bold'). */
152
+ valueWeight?: 'regular' | 'bold'
153
+ /** How wide the track may get before the row stops giving it room (default '100%'). */
136
154
  width?: string
137
155
  /** Model value for v-model */
138
156
  modelValue: number | null
@@ -148,6 +166,9 @@ const emit = defineEmits<{
148
166
  (e: 'update:modelValue', value: number | null): void
149
167
  }>()
150
168
 
169
+ const editInput = ref<HTMLInputElement | null>(null)
170
+ const sliderId = nextElementId('slider')
171
+
151
172
  const decimalsFromStep = (step: number): number => {
152
173
  if (!Number.isFinite(step) || step <= 0) return 0
153
174
  const s = step.toString()
@@ -257,8 +278,10 @@ const approxEqual = (a: number, b: number): boolean => {
257
278
 
258
279
  // Pill position logic
259
280
  const fillWidth = computed(() => {
281
+ const span = props.max - props.min
282
+ if (!(span > 0)) return 0
260
283
  const val = currentSliderValue.value
261
- return ((val - props.min) / (props.max - props.min)) * 100
284
+ return ((val - props.min) / span) * 100
262
285
  })
263
286
  const staticFillWidth = ref<number>(0)
264
287
 
@@ -269,8 +292,13 @@ const pillLeft = computed(() =>
269
292
  : fillWidth.value) / 100})`
270
293
  )
271
294
 
295
+ const skipEditCommit = ref(false)
296
+
297
+ // A fraction of a step apart is the same value: an exact comparison lets float drift through as a
298
+ // fresh commit.
272
299
  const sendValue = (val: number) => {
273
- if (val === lastSentValue.value) return
300
+ const eps = Math.max(1e-6, rawStep.value * 0.25)
301
+ if (Math.abs(val - lastSentValue.value) <= eps) return
274
302
  lastSentValue.value = val
275
303
  emit('update:modelValue', val)
276
304
  }
@@ -289,7 +317,9 @@ const clearCommitLock = (): void => {
289
317
  }
290
318
 
291
319
  const setCommitLock = (val: number): void => {
292
- const lockMs = 2000
320
+ // Long enough to swallow the echo of our own commit, short enough that a value the host drives
321
+ // on its own (a correlated axis, a preset being applied) is not held off the control.
322
+ const lockMs = 400
293
323
  commitLockValue.value = val
294
324
  commitLockUntilMs.value = Date.now() + lockMs
295
325
  if (commitLockTimeout) clearTimeout(commitLockTimeout)
@@ -313,6 +343,8 @@ const onSliderChange = (): void => {
313
343
 
314
344
  // Clamp during input
315
345
  const clampEditedValue = () => {
346
+ // An emptied number input reads as NaN, which must not be clamped into the value.
347
+ if (!Number.isFinite(editedDisplayValue.value)) return
316
348
  editedDisplayValue.value = Math.min(Math.max(editedDisplayValue.value, displayMin.value), displayMax.value)
317
349
  }
318
350
 
@@ -347,7 +379,12 @@ const flushPendingValue = (): void => {
347
379
  sendValue(toSend)
348
380
  }
349
381
 
382
+ // Reached from the pointer, from the range input's blur and from its change event, so it has to
383
+ // settle the interaction once however many of those arrive.
350
384
  const endInteracting = (): void => {
385
+ window.removeEventListener('pointerup', handlePointerUp)
386
+ window.removeEventListener('pointercancel', handlePointerCancel)
387
+ if (!isInteracting.value) return
351
388
  isInteracting.value = false
352
389
 
353
390
  const clamped = Math.min(Math.max(currentSliderValue.value, props.min), props.max)
@@ -363,10 +400,11 @@ const handlePointerCancel = (): void => endInteracting()
363
400
 
364
401
  const startInteracting = (): void => {
365
402
  if (props.disabled || isEditingCurrentSliderValue.value) return
403
+ if (isInteracting.value) return
366
404
  isInteracting.value = true
367
405
  clearCommitLock()
368
- window.addEventListener('pointerup', handlePointerUp, { once: true })
369
- window.addEventListener('pointercancel', handlePointerCancel, { once: true })
406
+ window.addEventListener('pointerup', handlePointerUp)
407
+ window.addEventListener('pointercancel', handlePointerCancel)
370
408
  }
371
409
 
372
410
  const isArrowKey = (key: string): boolean =>
@@ -412,17 +450,28 @@ const onRangeKeyup = (e: KeyboardEvent): void => {
412
450
  endInteracting()
413
451
  }
414
452
 
415
- // Keyboard handling
453
+ // Keyboard handling for the edit input.
416
454
  const handleValueChange = (e: KeyboardEvent): void => {
417
455
  if (e.key === 'Escape') {
418
- isEditingCurrentSliderValue.value = false
456
+ // Restore before leaving edit mode, so the watcher below has nothing to commit.
419
457
  editedDisplayValue.value = props.scaleFn ? props.scaleFn(lastSentValue.value) : lastSentValue.value
420
458
  currentSliderValue.value = lastSentValue.value
459
+ skipEditCommit.value = true
460
+ isEditingCurrentSliderValue.value = false
421
461
  } else if (e.key === 'Enter') {
422
462
  isEditingCurrentSliderValue.value = false
423
463
  }
424
464
  }
425
465
 
466
+ // A native number spinner blurs the input before it applies the step, so leaving edit mode waits
467
+ // long enough for the value that click was for to arrive.
468
+ const onEditBlur = (): void => {
469
+ window.setTimeout(() => {
470
+ if (document.activeElement === editInput.value) return
471
+ isEditingCurrentSliderValue.value = false
472
+ }, 150)
473
+ }
474
+
426
475
  // Sync from parent
427
476
  watch(
428
477
  () => props.modelValue,
@@ -431,12 +480,15 @@ watch(
431
480
  if (isEditingCurrentSliderValue.value || isInteracting.value) return
432
481
  if (
433
482
  commitLockValue.value !== null &&
434
- Date.now() < commitLockUntilMs.value &&
435
- !approxEqual(next, commitLockValue.value)
483
+ Date.now() < commitLockUntilMs.value
436
484
  ) {
437
- return
485
+ // The echo of our own commit is already on screen; anything else is the host speaking and
486
+ // outranks the lock.
487
+ if (approxEqual(next, commitLockValue.value)) return
488
+ clearCommitLock()
438
489
  }
439
490
  currentSliderValue.value = next
491
+ lastSentValue.value = next
440
492
  },
441
493
  { immediate: true }
442
494
  )
@@ -446,6 +498,13 @@ watch(isEditingCurrentSliderValue, (isEditing) => {
446
498
  editedDisplayValue.value = displayValue.value
447
499
  staticFillWidth.value = fillWidth.value
448
500
  } else {
501
+ if (skipEditCommit.value) {
502
+ skipEditCommit.value = false
503
+ return
504
+ }
505
+ if (!Number.isFinite(editedDisplayValue.value)) {
506
+ editedDisplayValue.value = displayValue.value
507
+ }
449
508
  const raw = props.unscaleFn ? props.unscaleFn(editedDisplayValue.value) : editedDisplayValue.value
450
509
  currentSliderValue.value = Math.min(Math.max(raw, props.min), props.max)
451
510
  sendValue(currentSliderValue.value)
@@ -1,13 +1,13 @@
1
1
  <template>
2
2
  <div class="flex w-full justify-between items-center">
3
- <!-- shrink-0 keeps the label at its natural width: letting flex shrink it by the fraction
4
- of a pixel that rounding introduces is enough for Chromium to ellipsize a label that
5
- fits. The cap is what makes an outsized label ellipsize instead of eating the row. -->
3
+ <!-- No cap: the control beside it yields a thousand times more readily, so the label keeps its
4
+ full text until the track is down to its own minimum, and only then does it ellipsize. -->
6
5
  <div
7
6
  v-if="label"
8
- class="min-w-0 max-w-[45%] shrink-0"
7
+ class="min-w-0"
9
8
  >
10
9
  <label
10
+ :id="labelId"
11
11
  class="block truncate text-start mr-6"
12
12
  :title="label"
13
13
  :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
@@ -28,45 +28,60 @@
28
28
  :class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
29
29
  />
30
30
  </BlueTooltip>
31
- <!-- Both labels are laid out rather than drawn over: two equal columns take their width
32
- from the longer of the two, so the track grows to whatever it has to say and the knob
33
- covering either side has the same room. -->
31
+ <!-- The knob carries the state's own name and slides over a track that only says what the
32
+ other position would be, so the switch reads as one word rather than two competing ones. -->
34
33
  <div
35
34
  name="switch-track"
36
- class="relative grid grid-cols-2 rounded-[8px] bluevue-elevation-1 cursor-pointer overflow-hidden"
35
+ role="switch"
36
+ :aria-labelledby="label ? labelId : undefined"
37
+ :aria-label="label ? undefined : name"
38
+ :aria-checked="modelValue"
39
+ :aria-disabled="disabled === true"
40
+ :tabindex="disabled ? -1 : 0"
41
+ class="relative inline-grid shrink-0 grid-cols-[1fr_1fr] rounded-[8px] bluevue-elevation-1 cursor-pointer overflow-hidden"
37
42
  :class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30 cursor-not-allowed' : '']"
38
- :style="{ minWidth: width || '75px', height: height || '30px' }"
43
+ :style="{ height: height || '30px' }"
39
44
  @click="toggleSwitch"
45
+ @keydown="onSwitchKeydown"
40
46
  >
47
+ <!-- The knob has to clear its own word by 5px at either end of its travel, and an absolute
48
+ child cannot tell the track how wide that is. These two carry the knob's own type,
49
+ unseen, so the equal columns measure the longer word and the track is born to fit. -->
50
+ <span
51
+ aria-hidden="true"
52
+ class="invisible whitespace-nowrap px-[7px] text-[14px]"
53
+ >{{ labelOff || 'Off' }}</span>
54
+ <span
55
+ aria-hidden="true"
56
+ class="invisible whitespace-nowrap px-[7px] text-[14px]"
57
+ >{{ labelOn || 'On' }}</span>
58
+ <span
59
+ class="absolute left-[8px] top-1/2 -translate-y-1/2 text-[11px] pointer-events-none"
60
+ :class="trackLabelClass"
61
+ >{{ labelOff || '' }}</span>
62
+ <span
63
+ class="absolute right-[8px] top-1/2 -translate-y-1/2 text-[11px] pointer-events-none"
64
+ :class="trackLabelClass"
65
+ >{{ labelOn || '' }}</span>
41
66
  <div
42
- class="absolute top-[4px] bottom-[4px] w-[calc(50%-4px)] rounded-[8px] bluevue-elevation-1 transition-all duration-300"
67
+ class="absolute top-[4px] bottom-[4px] left-[4px] flex items-center justify-center whitespace-nowrap rounded-[8px] px-[5px] text-[14px] text-white bluevue-elevation-1 transition-all duration-300 pointer-events-none"
43
68
  :style="{
44
- left: modelValue ? 'calc(50% + 2px)' : '2px',
69
+ width: 'calc(50% - 4px)',
70
+ transform: modelValue ? 'translateX(100%)' : 'none',
45
71
  backgroundColor: modelValue ? color || 'var(--bluevue-primary)' : '#777777',
46
72
  }"
47
- />
48
- <!-- 9px, of which the knob's 2px inset takes the first two: what is left is the 7px the
49
- label keeps from the knob's edge. -->
50
- <span
51
- class="relative flex items-center justify-center px-[9px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
52
- :class="labelClass(!modelValue)"
53
73
  >
54
- {{ labelOff || 'Off' }}
55
- </span>
56
- <span
57
- class="relative flex items-center justify-center px-[9px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
58
- :class="labelClass(modelValue)"
59
- >
60
- {{ labelOn || 'On' }}
61
- </span>
74
+ {{ modelValue ? labelOn || 'On' : labelOff || 'Off' }}
75
+ </div>
62
76
  </div>
63
77
  </div>
64
78
  </div>
65
79
  </template>
66
80
 
67
81
  <script setup lang="ts">
68
- import { ref, watch } from 'vue'
82
+ import { computed, ref, watch } from 'vue'
69
83
 
84
+ import { nextElementId } from '../utils/id'
70
85
  import BlueIcon from './BlueIcon.vue'
71
86
  import BlueTooltip from './BlueTooltip.vue'
72
87
 
@@ -91,8 +106,6 @@ const props = defineProps<{
91
106
  name: string
92
107
  /** Theme of the component. */
93
108
  theme?: 'light' | 'dark'
94
- /** Minimum width of the container. */
95
- width?: string
96
109
  }>()
97
110
 
98
111
  const emit = defineEmits<{
@@ -100,11 +113,11 @@ const emit = defineEmits<{
100
113
  }>()
101
114
 
102
115
  const modelValue = ref(props.modelValue || false)
116
+ const labelId = nextElementId('switch-label')
103
117
 
104
- // The label the knob sits under is white against its fill; the other one stays as a dim
105
- // reminder of what the far position says.
106
- const labelClass = (active: boolean): string[] =>
107
- active ? ['text-white'] : ['opacity-20', props.theme === 'dark' ? 'text-white' : 'text-black']
118
+ const trackLabelClass = computed(() =>
119
+ props.theme === 'dark' ? 'text-[#ffffff44]' : 'text-[#00000066]'
120
+ )
108
121
 
109
122
  const toggleSwitch = (): void => {
110
123
  if (props.disabled) return
@@ -112,6 +125,14 @@ const toggleSwitch = (): void => {
112
125
  emit('update:modelValue', modelValue.value)
113
126
  }
114
127
 
128
+ // The track is a div, so the keys a native checkbox would answer to have to be handled here.
129
+ const onSwitchKeydown = (event: KeyboardEvent): void => {
130
+ if (props.disabled) return
131
+ if (event.key !== 'Enter' && event.key !== ' ') return
132
+ event.preventDefault()
133
+ toggleSwitch()
134
+ }
135
+
115
136
  watch(
116
137
  () => props.modelValue,
117
138
  (v) => (modelValue.value = v ?? false),
@@ -73,9 +73,11 @@ onBeforeUnmount(cancelOpen)
73
73
  :style="floatingStyles"
74
74
  @toggle="onToggle"
75
75
  >
76
+ <!-- pre-wrap so a caller can break the hint into lines, and hold a column of figures on a
77
+ tab stop, with one string and no markup. Long prose still wraps at the cap. -->
76
78
  <div
77
79
  role="tooltip"
78
- class="bluevue-elevation-5 max-w-[280px] rounded-[4px] px-2 py-1 text-xs leading-snug transition-opacity duration-150"
80
+ class="bluevue-elevation-5 max-w-[280px] whitespace-pre-wrap rounded-[4px] px-2 py-1 text-xs leading-snug transition-opacity duration-150"
79
81
  :class="[
80
82
  theme === 'light' ? 'bg-white text-black' : 'bg-[#333333f2] text-white',
81
83
  isPositioned ? 'opacity-100' : 'opacity-0',
@@ -21,6 +21,8 @@ const CARDINALS: { label: string; deg: number }[] = [
21
21
  */
22
22
  const props = defineProps<{
23
23
  modelValue: number | null
24
+ /** Marks the heading with a plain bar, for a value that is an angle rather than a wind. */
25
+ angle?: boolean
24
26
  label?: string
25
27
  name?: string
26
28
  /** Start of the circle (default 0). */
@@ -125,6 +127,20 @@ function tickPoint(deg: number, radius: number): { x: number; y: number } {
125
127
  const rad = ((deg - 90) * Math.PI) / 180
126
128
  return { x: CENTER + Math.cos(rad) * radius, y: CENTER + Math.sin(rad) * radius }
127
129
  }
130
+
131
+ // A wind is named for where it comes from, so the arrow flies inward: the tail sits on the rim at
132
+ // the heading and the head reaches the centre. An angle comes from nowhere, so it loses the head
133
+ // and the bar simply marks the direction it points in.
134
+ const indicatorPath = computed(() => {
135
+ const rim = 18
136
+ const shaft = 2.2
137
+ if (props.angle) {
138
+ return `M ${CENTER - shaft} ${rim} L ${CENTER + shaft} ${rim} L ${CENTER + shaft} ${CENTER} L ${CENTER - shaft} ${CENTER} Z`
139
+ }
140
+ const barb = 7
141
+ const neck = CENTER - 18
142
+ return `M ${CENTER} ${CENTER} L ${CENTER + barb} ${neck} L ${CENTER + shaft} ${neck} L ${CENTER + shaft} ${rim} L ${CENTER - shaft} ${rim} L ${CENTER - shaft} ${neck} L ${CENTER - barb} ${neck} Z`
143
+ })
128
144
  </script>
129
145
 
130
146
  <template>
@@ -252,7 +268,7 @@ function tickPoint(deg: number, radius: number): { x: number; y: number } {
252
268
  :filter="`url(#${popoverId}-arrow-shadow)`"
253
269
  >
254
270
  <path
255
- :d="`M ${CENTER} 18 L ${CENTER + 7} 36 L ${CENTER + 2.2} 36 L ${CENTER + 2.2} ${CENTER} L ${CENTER - 2.2} ${CENTER} L ${CENTER - 2.2} 36 L ${CENTER - 7} 36 Z`"
271
+ :d="indicatorPath"
256
272
  fill="#F5C400"
257
273
  />
258
274
  <circle
package/src/index.ts CHANGED
@@ -10,6 +10,12 @@ export { default as BlueConfirmDialog } from './components/BlueConfirmDialog.vue
10
10
  export { default as BlueDialog } from './components/BlueDialog.vue'
11
11
  export { default as BlueExpansiblePanel } from './components/BlueExpansiblePanel.vue'
12
12
  export { default as BlueFileDrop } from './components/BlueFileDrop.vue'
13
+ export { default as BlueHeader } from './components/BlueHeader.vue'
14
+ export { default as BlueHeaderMenu } from './components/BlueHeaderMenu.vue'
15
+ export {
16
+ default as BlueHeaderSelector,
17
+ type BlueHeaderSelectorItem,
18
+ } from './components/BlueHeaderSelector.vue'
13
19
  export { default as BlueIcon } from './components/BlueIcon.vue'
14
20
  export { default as BlueInput } from './components/BlueInput.vue'
15
21
  export { default as BlueStepsDialog, type BlueStep } from './components/BlueStepsDialog.vue'
@@ -9,9 +9,12 @@
9
9
  --bluevue-accent: #6699CC;
10
10
  --bluevue-panel-bg: rgba(30, 30, 30, 0.96);
11
11
  --bluevue-hairline: rgba(255, 255, 255, 0.08);
12
- /* Material's three-part shadow, at the two depths the controls use: a resting control, and
13
- one raised above its neighbours (a selected segment, an open menu). */
12
+ /* Material's three-part shadow, across the five depths a control can rest at: 1 sits a hair off
13
+ the surface, 5 floats above its neighbours (a selected segment, an open menu). */
14
14
  --bluevue-elevation-1: 0 2px 1px -1px #0003, 0 1px 1px 0 #00000024, 0 1px 3px 0 #0000001f;
15
+ --bluevue-elevation-2: 0 3px 1px -2px #0003, 0 2px 2px 0 #00000024, 0 1px 5px 0 #0000001f;
16
+ --bluevue-elevation-3: 0 3px 3px -2px #0003, 0 3px 4px 0 #00000024, 0 1px 8px 0 #0000001f;
17
+ --bluevue-elevation-4: 0 2px 4px -1px #0003, 0 4px 5px 0 #00000024, 0 1px 10px 0 #0000001f;
15
18
  --bluevue-elevation-5: 0 3px 5px -1px #0003, 0 5px 8px 0 #00000024, 0 1px 14px 0 #0000001f;
16
19
  /* The opposite of an elevation: a well cut into the surface, for somewhere a value is typed
17
20
  rather than somewhere that is pressed. The light line along the bottom edge is what sells
@@ -26,6 +29,18 @@
26
29
  box-shadow: var(--bluevue-elevation-1);
27
30
  }
28
31
 
32
+ .bluevue-elevation-2 {
33
+ box-shadow: var(--bluevue-elevation-2);
34
+ }
35
+
36
+ .bluevue-elevation-3 {
37
+ box-shadow: var(--bluevue-elevation-3);
38
+ }
39
+
40
+ .bluevue-elevation-4 {
41
+ box-shadow: var(--bluevue-elevation-4);
42
+ }
43
+
29
44
  .bluevue-elevation-5 {
30
45
  box-shadow: var(--bluevue-elevation-5);
31
46
  }
@@ -38,11 +53,13 @@
38
53
  box-shadow: var(--bluevue-elevation-1-soft);
39
54
  }
40
55
 
41
- /* The surface the dialogs are built on: a frosted near-black card with a hairline edge. */
56
+ /* The surface the dialogs are built on: a frosted near-black card with a hairline edge.
57
+ The -webkit- spelling goes first here and below: written the other way round, minification keeps
58
+ only the last of the pair, and Chromium dropped the prefixed alias, so the frosting was lost. */
42
59
  .bluevue-panel {
43
60
  background-color: var(--bluevue-panel-bg);
44
- backdrop-filter: blur(8px);
45
61
  -webkit-backdrop-filter: blur(8px);
62
+ backdrop-filter: blur(8px);
46
63
  border: 1px solid var(--bluevue-hairline);
47
64
  box-shadow: 0 4px 4px rgba(0, 0, 0, 0.2), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
48
65
  }
@@ -59,9 +76,18 @@
59
76
  overflow: visible;
60
77
  }
61
78
 
62
- /* showModal() moves focus into the dialog, and with nothing focusable inside it the dialog
63
- itself takes it and the browser rings the whole panel. The ring belongs on controls, not on
64
- the surface holding them, and the controls inside keep their own. */
79
+ /* The cap above has to reach the panel, or a tall dialog paints its footer past the viewport with
80
+ nothing able to scroll it back: an element in the top layer does not move with the page. A column
81
+ here hands the panel the dialog's own height, and the panel scrolls its body instead. Written
82
+ against [open] so the closed element keeps the display: none the UA gives it. */
83
+ .bluevue-dialog[open] {
84
+ display: flex;
85
+ flex-direction: column;
86
+ }
87
+
88
+ /* showModal() moves focus into the dialog, and the dialog itself is what takes it, so the browser
89
+ rings the whole panel. The ring belongs on controls, not on the surface holding them, and the
90
+ controls inside keep their own. */
65
91
  .bluevue-dialog:focus,
66
92
  .bluevue-dialog:focus-visible,
67
93
  .bluevue-popover:focus,
@@ -71,8 +97,8 @@
71
97
 
72
98
  .bluevue-dialog::backdrop {
73
99
  background: rgba(0, 0, 0, 0.88);
74
- backdrop-filter: blur(8px);
75
100
  -webkit-backdrop-filter: blur(8px);
101
+ backdrop-filter: blur(8px);
76
102
  }
77
103
 
78
104
  /* A popover is promoted to the top layer, so it needs no z-index and is not clipped by the
@@ -91,8 +117,8 @@
91
117
  backdrop at half its weight, since the page under it stays live and clickable. */
92
118
  .bluevue-popover--dimmed::backdrop {
93
119
  background: rgba(0, 0, 0, 0.44);
94
- backdrop-filter: blur(8px);
95
120
  -webkit-backdrop-filter: blur(8px);
121
+ backdrop-filter: blur(8px);
96
122
  }
97
123
 
98
124
  /* An icon is a box the size of its em, not of however wide its glyph advances, so a row of them
@@ -224,23 +250,14 @@
224
250
  position: absolute;
225
251
  }
226
252
 
227
- /* BlueExpansiblePanel's open and close. */
253
+ /* BlueExpansiblePanel's open and close. The two ends are set from the panel's measured height by
254
+ the component, so only the transition itself lives here. */
228
255
  .bluevue-expand-enter-active,
229
256
  .bluevue-expand-leave-active {
230
257
  transition: max-height 0.3s ease;
231
258
  overflow: hidden;
232
259
  }
233
260
 
234
- .bluevue-expand-enter-from,
235
- .bluevue-expand-leave-to {
236
- max-height: 0;
237
- }
238
-
239
- .bluevue-expand-enter-to,
240
- .bluevue-expand-leave-from {
241
- max-height: 500px;
242
- }
243
-
244
261
  /* BlueLoadingDialog's mark is a propeller, so it turns like one: a creep of 80 degrees over two
245
262
  seconds, then the balance of four turns over the next two, carrying it round to where it
246
263
  started. */
@@ -0,0 +1,15 @@
1
+ let counter = 0
2
+
3
+ /**
4
+ * A document-unique id, for wiring a label to the control it names.
5
+ *
6
+ * Stands in for Vue's `useId`, which arrived in 3.5 and so is out of reach of the 3.4 baseline
7
+ * this package supports.
8
+ *
9
+ * @param prefix - Short name of the control asking for it, so ids stay readable in the DOM.
10
+ * @returns The generated id.
11
+ */
12
+ export function nextElementId(prefix: string): string {
13
+ counter += 1
14
+ return `bluevue-${prefix}-${counter}`
15
+ }