@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.
- package/dist/bluevue.js +1907 -1624
- 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 +19 -10
- package/src/components/BlueCheckbox.vue +2 -2
- 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/BlueInput.vue +3 -3
- package/src/components/BlueLoadingDialog.vue +1 -1
- package/src/components/BlueSelect.vue +2 -2
- package/src/components/BlueSlider.vue +74 -16
- package/src/components/BlueSwitch.vue +56 -35
- package/src/components/BlueTextarea.vue +2 -1
- package/src/components/BlueWindRose.vue +21 -3
- package/src/index.ts +6 -0
- package/src/styles/bluevue.css +39 -16
- 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
|
-
|
|
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]
|
|
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="{
|
|
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="
|
|
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="
|
|
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
|
-
/**
|
|
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) /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
369
|
-
window.addEventListener('pointercancel', handlePointerCancel
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
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
|
-
: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="
|
|
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
|
-
<!--
|
|
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),
|
|
@@ -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-
|
|
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="
|
|
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'
|
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
|
}
|
|
@@ -59,9 +74,18 @@
|
|
|
59
74
|
overflow: visible;
|
|
60
75
|
}
|
|
61
76
|
|
|
62
|
-
/*
|
|
63
|
-
|
|
64
|
-
the
|
|
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. */
|
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
|
+
}
|