@bluerobotics/bluevue 0.2.0 → 0.2.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.
Files changed (34) hide show
  1. package/dist/bluevue.js +1907 -1624
  2. package/dist/components/BlueButton.vue.d.ts +5 -0
  3. package/dist/components/BlueButtonGroup.vue.d.ts +6 -0
  4. package/dist/components/BlueConfirmDialog.vue.d.ts +98 -21
  5. package/dist/components/BlueDialog.vue.d.ts +51 -4
  6. package/dist/components/BlueExpansiblePanel.vue.d.ts +11 -0
  7. package/dist/components/BlueHeader.vue.d.ts +41 -0
  8. package/dist/components/BlueHeaderMenu.vue.d.ts +25 -0
  9. package/dist/components/BlueHeaderSelector.vue.d.ts +42 -0
  10. package/dist/components/BlueSlider.vue.d.ts +5 -1
  11. package/dist/components/BlueSwitch.vue.d.ts +0 -2
  12. package/dist/components/BlueWindRose.vue.d.ts +2 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/style.css +1 -1
  15. package/dist/utils/id.d.ts +10 -0
  16. package/package.json +1 -1
  17. package/src/components/BlueButton.vue +22 -1
  18. package/src/components/BlueButtonGroup.vue +19 -10
  19. package/src/components/BlueCheckbox.vue +2 -2
  20. package/src/components/BlueDialog.vue +46 -7
  21. package/src/components/BlueExpansiblePanel.vue +63 -9
  22. package/src/components/BlueHeader.vue +42 -0
  23. package/src/components/BlueHeaderMenu.vue +53 -0
  24. package/src/components/BlueHeaderSelector.vue +153 -0
  25. package/src/components/BlueInput.vue +3 -3
  26. package/src/components/BlueLoadingDialog.vue +1 -1
  27. package/src/components/BlueSelect.vue +2 -2
  28. package/src/components/BlueSlider.vue +74 -16
  29. package/src/components/BlueSwitch.vue +56 -35
  30. package/src/components/BlueTextarea.vue +2 -1
  31. package/src/components/BlueWindRose.vue +21 -3
  32. package/src/index.ts +6 -0
  33. package/src/styles/bluevue.css +39 -16
  34. package/src/utils/id.ts +15 -0
@@ -6,19 +6,27 @@
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. The
19
+ floor is what gives way first when the host narrows: the track keeps a usable length and
20
+ takes more than half the row rather than collapsing with it. -->
21
+ <div class="flex w-1/2 min-w-[140px] items-center justify-start">
22
+ <!-- isolate keeps the pill and the min/max labels stacked against the track alone: without
23
+ it their z-index is resolved against the page, and they paint over whatever chrome the
24
+ host has floating above the row. -->
17
25
  <div
18
26
  name="slider-track"
19
- class="relative overflow-visible rounded-[6px] bluevue-elevation-1 min-w-[140px] max-w-full"
27
+ class="relative isolate w-full overflow-visible rounded-[6px] bluevue-elevation-1 min-w-[140px]"
20
28
  :class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30' : '']"
21
- :style="{ width: width || '100%', height: height || '30px', cursor: disabled ? 'not-allowed' : 'pointer' }"
29
+ :style="{ maxWidth: width || '100%', height: height || '30px', cursor: disabled ? 'not-allowed' : 'pointer' }"
22
30
  >
23
31
  <div class="absolute inset-x-[18%] top-1/2 -translate-y-1/2 flex justify-between pointer-events-none">
24
32
  <div
@@ -42,7 +50,8 @@
42
50
  >
43
51
  <div v-if="!isEditingCurrentSliderValue">
44
52
  <p
45
- class="font-bold leading-none select-none"
53
+ class="leading-none select-none"
54
+ :class="valueWeight === 'regular' ? 'font-normal' : 'font-bold'"
46
55
  draggable="false"
47
56
  >
48
57
  {{ formatDisplay ? formatDisplay(scaledValue) : scaledValue.toFixed(defaultDecimals) }}
@@ -53,6 +62,7 @@
53
62
  ref="editInput"
54
63
  v-model.number="editedDisplayValue"
55
64
  type="number"
65
+ :aria-label="label"
56
66
  :min="displayMin"
57
67
  :max="displayMax"
58
68
  :step="displayStep"
@@ -60,11 +70,12 @@
60
70
  class="bg-white border border-gray-300 rounded px-1 py-0.5"
61
71
  @input="clampEditedValue"
62
72
  @keydown="handleValueChange"
63
- @blur="isEditingCurrentSliderValue = false"
73
+ @blur="onEditBlur"
64
74
  >
65
75
  </div>
66
76
  </div>
67
77
  <input
78
+ :id="sliderId"
68
79
  v-model.number="currentSliderValue"
69
80
  type="range"
70
81
  class="absolute inset-0 w-full h-full opacity-0"
@@ -107,6 +118,8 @@
107
118
  <script setup lang="ts">
108
119
  import { ref, watch, onBeforeUnmount, computed } from 'vue'
109
120
 
121
+ import { nextElementId } from '../utils/id'
122
+
110
123
  const props = defineProps<{
111
124
  /** Pill color override. */
112
125
  color?: string
@@ -116,6 +129,8 @@ const props = defineProps<{
116
129
  height?: string
117
130
  /** Main Label text on the left. */
118
131
  label?: string
132
+ /** Floor for the label column, so a column of sliders starts its tracks at one place. */
133
+ labelWidth?: string
119
134
  /** Custom text for the max label. */
120
135
  labelMax?: string
121
136
  /** Custom text for the min label. */
@@ -132,7 +147,9 @@ const props = defineProps<{
132
147
  keyboardStepMultiplierLimit?: number
133
148
  /** 'light' or 'dark' theme. (default 'light')*/
134
149
  theme?: 'light' | 'dark' | 'transparent'
135
- /** Container width (default '100%'). */
150
+ /** Weight of the value in the pill (default 'bold'). */
151
+ valueWeight?: 'regular' | 'bold'
152
+ /** How wide the track may get before the row stops giving it room (default '100%'). */
136
153
  width?: string
137
154
  /** Model value for v-model */
138
155
  modelValue: number | null
@@ -148,6 +165,9 @@ const emit = defineEmits<{
148
165
  (e: 'update:modelValue', value: number | null): void
149
166
  }>()
150
167
 
168
+ const editInput = ref<HTMLInputElement | null>(null)
169
+ const sliderId = nextElementId('slider')
170
+
151
171
  const decimalsFromStep = (step: number): number => {
152
172
  if (!Number.isFinite(step) || step <= 0) return 0
153
173
  const s = step.toString()
@@ -257,8 +277,10 @@ const approxEqual = (a: number, b: number): boolean => {
257
277
 
258
278
  // Pill position logic
259
279
  const fillWidth = computed(() => {
280
+ const span = props.max - props.min
281
+ if (!(span > 0)) return 0
260
282
  const val = currentSliderValue.value
261
- return ((val - props.min) / (props.max - props.min)) * 100
283
+ return ((val - props.min) / span) * 100
262
284
  })
263
285
  const staticFillWidth = ref<number>(0)
264
286
 
@@ -269,8 +291,13 @@ const pillLeft = computed(() =>
269
291
  : fillWidth.value) / 100})`
270
292
  )
271
293
 
294
+ const skipEditCommit = ref(false)
295
+
296
+ // A fraction of a step apart is the same value: an exact comparison lets float drift through as a
297
+ // fresh commit.
272
298
  const sendValue = (val: number) => {
273
- if (val === lastSentValue.value) return
299
+ const eps = Math.max(1e-6, rawStep.value * 0.25)
300
+ if (Math.abs(val - lastSentValue.value) <= eps) return
274
301
  lastSentValue.value = val
275
302
  emit('update:modelValue', val)
276
303
  }
@@ -289,7 +316,9 @@ const clearCommitLock = (): void => {
289
316
  }
290
317
 
291
318
  const setCommitLock = (val: number): void => {
292
- const lockMs = 2000
319
+ // Long enough to swallow the echo of our own commit, short enough that a value the host drives
320
+ // on its own (a correlated axis, a preset being applied) is not held off the control.
321
+ const lockMs = 400
293
322
  commitLockValue.value = val
294
323
  commitLockUntilMs.value = Date.now() + lockMs
295
324
  if (commitLockTimeout) clearTimeout(commitLockTimeout)
@@ -313,6 +342,8 @@ const onSliderChange = (): void => {
313
342
 
314
343
  // Clamp during input
315
344
  const clampEditedValue = () => {
345
+ // An emptied number input reads as NaN, which must not be clamped into the value.
346
+ if (!Number.isFinite(editedDisplayValue.value)) return
316
347
  editedDisplayValue.value = Math.min(Math.max(editedDisplayValue.value, displayMin.value), displayMax.value)
317
348
  }
318
349
 
@@ -347,7 +378,12 @@ const flushPendingValue = (): void => {
347
378
  sendValue(toSend)
348
379
  }
349
380
 
381
+ // Reached from the pointer, from the range input's blur and from its change event, so it has to
382
+ // settle the interaction once however many of those arrive.
350
383
  const endInteracting = (): void => {
384
+ window.removeEventListener('pointerup', handlePointerUp)
385
+ window.removeEventListener('pointercancel', handlePointerCancel)
386
+ if (!isInteracting.value) return
351
387
  isInteracting.value = false
352
388
 
353
389
  const clamped = Math.min(Math.max(currentSliderValue.value, props.min), props.max)
@@ -363,10 +399,11 @@ const handlePointerCancel = (): void => endInteracting()
363
399
 
364
400
  const startInteracting = (): void => {
365
401
  if (props.disabled || isEditingCurrentSliderValue.value) return
402
+ if (isInteracting.value) return
366
403
  isInteracting.value = true
367
404
  clearCommitLock()
368
- window.addEventListener('pointerup', handlePointerUp, { once: true })
369
- window.addEventListener('pointercancel', handlePointerCancel, { once: true })
405
+ window.addEventListener('pointerup', handlePointerUp)
406
+ window.addEventListener('pointercancel', handlePointerCancel)
370
407
  }
371
408
 
372
409
  const isArrowKey = (key: string): boolean =>
@@ -412,17 +449,28 @@ const onRangeKeyup = (e: KeyboardEvent): void => {
412
449
  endInteracting()
413
450
  }
414
451
 
415
- // Keyboard handling
452
+ // Keyboard handling for the edit input.
416
453
  const handleValueChange = (e: KeyboardEvent): void => {
417
454
  if (e.key === 'Escape') {
418
- isEditingCurrentSliderValue.value = false
455
+ // Restore before leaving edit mode, so the watcher below has nothing to commit.
419
456
  editedDisplayValue.value = props.scaleFn ? props.scaleFn(lastSentValue.value) : lastSentValue.value
420
457
  currentSliderValue.value = lastSentValue.value
458
+ skipEditCommit.value = true
459
+ isEditingCurrentSliderValue.value = false
421
460
  } else if (e.key === 'Enter') {
422
461
  isEditingCurrentSliderValue.value = false
423
462
  }
424
463
  }
425
464
 
465
+ // A native number spinner blurs the input before it applies the step, so leaving edit mode waits
466
+ // long enough for the value that click was for to arrive.
467
+ const onEditBlur = (): void => {
468
+ window.setTimeout(() => {
469
+ if (document.activeElement === editInput.value) return
470
+ isEditingCurrentSliderValue.value = false
471
+ }, 150)
472
+ }
473
+
426
474
  // Sync from parent
427
475
  watch(
428
476
  () => props.modelValue,
@@ -431,12 +479,15 @@ watch(
431
479
  if (isEditingCurrentSliderValue.value || isInteracting.value) return
432
480
  if (
433
481
  commitLockValue.value !== null &&
434
- Date.now() < commitLockUntilMs.value &&
435
- !approxEqual(next, commitLockValue.value)
482
+ Date.now() < commitLockUntilMs.value
436
483
  ) {
437
- return
484
+ // The echo of our own commit is already on screen; anything else is the host speaking and
485
+ // outranks the lock.
486
+ if (approxEqual(next, commitLockValue.value)) return
487
+ clearCommitLock()
438
488
  }
439
489
  currentSliderValue.value = next
490
+ lastSentValue.value = next
440
491
  },
441
492
  { immediate: true }
442
493
  )
@@ -446,6 +497,13 @@ watch(isEditingCurrentSliderValue, (isEditing) => {
446
497
  editedDisplayValue.value = displayValue.value
447
498
  staticFillWidth.value = fillWidth.value
448
499
  } else {
500
+ if (skipEditCommit.value) {
501
+ skipEditCommit.value = false
502
+ return
503
+ }
504
+ if (!Number.isFinite(editedDisplayValue.value)) {
505
+ editedDisplayValue.value = displayValue.value
506
+ }
449
507
  const raw = props.unscaleFn ? props.unscaleFn(editedDisplayValue.value) : editedDisplayValue.value
450
508
  currentSliderValue.value = Math.min(Math.max(raw, props.min), props.max)
451
509
  sendValue(currentSliderValue.value)
@@ -1,16 +1,16 @@
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
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
13
+ :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
14
14
  >{{ label }}</label>
15
15
  </div>
16
16
  <div class="flex items-center min-w-0 shrink-[1000]">
@@ -24,49 +24,64 @@
24
24
  name="mdi-information-outline"
25
25
  :size="16"
26
26
  :label="infoTooltip"
27
- class="opacity-60 cursor-help"
28
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
27
+ class="cursor-help"
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),
@@ -69,7 +69,8 @@ const rows = computed(() => {
69
69
  name="mdi-information-outline"
70
70
  :size="16"
71
71
  :label="infoTooltip"
72
- class="opacity-60 cursor-help"
72
+ class="cursor-help"
73
+ :class="disabled ? 'opacity-30' : 'opacity-60'"
73
74
  />
74
75
  </BlueTooltip>
75
76
  </div>
@@ -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>
@@ -169,12 +185,14 @@ function tickPoint(deg: number, radius: number): { x: number; y: number } {
169
185
  :id="popoverId"
170
186
  ref="popoverRef"
171
187
  popover
172
- class="bluevue-popover"
188
+ class="bluevue-popover bluevue-popover--dimmed"
173
189
  :style="floatingStyles"
174
190
  @toggle="onToggle"
175
191
  >
192
+ <!-- No padding of its own: the rose is drawn 6px inside its own box, which is the margin the
193
+ round card keeps outside the compass ring. -->
176
194
  <div
177
- class="bluevue-elevation-5 rounded-[8px] p-3"
195
+ class="bluevue-elevation-5 rounded-full"
178
196
  :class="[
179
197
  isDark ? 'bg-[var(--bluevue-surface)] text-white' : 'bg-white text-black',
180
198
  isPositioned ? 'opacity-100' : 'opacity-0',
@@ -250,7 +268,7 @@ function tickPoint(deg: number, radius: number): { x: number; y: number } {
250
268
  :filter="`url(#${popoverId}-arrow-shadow)`"
251
269
  >
252
270
  <path
253
- :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"
254
272
  fill="#F5C400"
255
273
  />
256
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
  }
@@ -59,9 +74,18 @@
59
74
  overflow: visible;
60
75
  }
61
76
 
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. */
77
+ /* The cap above has to reach the panel, or a tall dialog paints its footer past the viewport with
78
+ nothing able to scroll it back: an element in the top layer does not move with the page. A column
79
+ here hands the panel the dialog's own height, and the panel scrolls its body instead. Written
80
+ against [open] so the closed element keeps the display: none the UA gives it. */
81
+ .bluevue-dialog[open] {
82
+ display: flex;
83
+ flex-direction: column;
84
+ }
85
+
86
+ /* showModal() moves focus into the dialog, and the dialog itself is what takes it, so the browser
87
+ rings the whole panel. The ring belongs on controls, not on the surface holding them, and the
88
+ controls inside keep their own. */
65
89
  .bluevue-dialog:focus,
66
90
  .bluevue-dialog:focus-visible,
67
91
  .bluevue-popover:focus,
@@ -87,6 +111,14 @@
87
111
  overflow: visible;
88
112
  }
89
113
 
114
+ /* For a popover that is aimed at rather than picked from, such as a wind rose: the dialog's
115
+ backdrop at half its weight, since the page under it stays live and clickable. */
116
+ .bluevue-popover--dimmed::backdrop {
117
+ background: rgba(0, 0, 0, 0.44);
118
+ backdrop-filter: blur(8px);
119
+ -webkit-backdrop-filter: blur(8px);
120
+ }
121
+
90
122
  /* An icon is a box the size of its em, not of however wide its glyph advances, so a row of them
91
123
  lines up. Written against .mdi as well, since the icon font's own rules say otherwise and a
92
124
  consumer is free to load the two stylesheets in either order. */
@@ -216,23 +248,14 @@
216
248
  position: absolute;
217
249
  }
218
250
 
219
- /* BlueExpansiblePanel's open and close. */
251
+ /* BlueExpansiblePanel's open and close. The two ends are set from the panel's measured height by
252
+ the component, so only the transition itself lives here. */
220
253
  .bluevue-expand-enter-active,
221
254
  .bluevue-expand-leave-active {
222
255
  transition: max-height 0.3s ease;
223
256
  overflow: hidden;
224
257
  }
225
258
 
226
- .bluevue-expand-enter-from,
227
- .bluevue-expand-leave-to {
228
- max-height: 0;
229
- }
230
-
231
- .bluevue-expand-enter-to,
232
- .bluevue-expand-leave-from {
233
- max-height: 500px;
234
- }
235
-
236
259
  /* BlueLoadingDialog's mark is a propeller, so it turns like one: a creep of 80 degrees over two
237
260
  seconds, then the balance of four turns over the next two, carrying it round to where it
238
261
  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
+ }