@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.
- package/dist/bluevue.js +1932 -1649
- package/dist/components/BlueButton.vue.d.ts +5 -0
- package/dist/components/BlueButtonGroup.vue.d.ts +6 -0
- package/dist/components/BlueConfirmDialog.vue.d.ts +98 -21
- package/dist/components/BlueDialog.vue.d.ts +51 -4
- package/dist/components/BlueExpansiblePanel.vue.d.ts +11 -0
- package/dist/components/BlueHeader.vue.d.ts +41 -0
- package/dist/components/BlueHeaderMenu.vue.d.ts +25 -0
- package/dist/components/BlueHeaderSelector.vue.d.ts +42 -0
- package/dist/components/BlueSlider.vue.d.ts +5 -1
- package/dist/components/BlueSwitch.vue.d.ts +0 -2
- package/dist/components/BlueWindRose.vue.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/style.css +1 -1
- package/dist/utils/id.d.ts +10 -0
- package/package.json +1 -1
- package/src/components/BlueButton.vue +22 -1
- package/src/components/BlueButtonGroup.vue +16 -7
- package/src/components/BlueDialog.vue +46 -7
- package/src/components/BlueExpansiblePanel.vue +63 -9
- package/src/components/BlueHeader.vue +42 -0
- package/src/components/BlueHeaderMenu.vue +53 -0
- package/src/components/BlueHeaderSelector.vue +153 -0
- package/src/components/BlueSlider.vue +75 -16
- package/src/components/BlueSwitch.vue +53 -32
- package/src/components/BlueTooltip.vue +3 -1
- package/src/components/BlueWindRose.vue +17 -1
- package/src/index.ts +6 -0
- package/src/styles/bluevue.css +37 -20
- package/src/utils/id.ts +15 -0
|
@@ -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
|
-
|
|
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]
|
|
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="{
|
|
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="
|
|
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="
|
|
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
|
-
/**
|
|
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) /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
369
|
-
window.addEventListener('pointercancel', handlePointerCancel
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
4
|
-
|
|
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
|
|
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
|
-
<!--
|
|
32
|
-
|
|
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
|
-
|
|
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="{
|
|
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]
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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="
|
|
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'
|
package/src/styles/bluevue.css
CHANGED
|
@@ -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,
|
|
13
|
-
|
|
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
|
-
/*
|
|
63
|
-
|
|
64
|
-
the
|
|
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. */
|
package/src/utils/id.ts
ADDED
|
@@ -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
|
+
}
|