@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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A document-unique id, for wiring a label to the control it names.
|
|
3
|
+
*
|
|
4
|
+
* Stands in for Vue's `useId`, which arrived in 3.5 and so is out of reach of the 3.4 baseline
|
|
5
|
+
* this package supports.
|
|
6
|
+
*
|
|
7
|
+
* @param prefix - Short name of the control asking for it, so ids stay readable in the DOM.
|
|
8
|
+
* @returns The generated id.
|
|
9
|
+
*/
|
|
10
|
+
export declare function nextElementId(prefix: string): string;
|
package/package.json
CHANGED
|
@@ -28,6 +28,11 @@ const props = defineProps<{
|
|
|
28
28
|
disabled?: boolean
|
|
29
29
|
/** Takes the whole width it is given, for a button ending a narrow panel. */
|
|
30
30
|
block?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Shadow depth, 1 (a hair off the surface) to 5. Chips default to 1; text and icon buttons have
|
|
33
|
+
* no chip to lift, so they default to none. A disabled button is never raised.
|
|
34
|
+
*/
|
|
35
|
+
elevation?: 1 | 2 | 3 | 4 | 5
|
|
31
36
|
/** Hover text, and the name assistive technology reads on an icon button. */
|
|
32
37
|
tooltip?: string
|
|
33
38
|
theme?: 'light' | 'dark'
|
|
@@ -57,7 +62,7 @@ const look = computed(() => {
|
|
|
57
62
|
}
|
|
58
63
|
switch (variant.value) {
|
|
59
64
|
case 'filled':
|
|
60
|
-
return '
|
|
65
|
+
return 'cursor-pointer text-white hover:brightness-125'
|
|
61
66
|
case 'text':
|
|
62
67
|
return isDark.value
|
|
63
68
|
? 'cursor-pointer text-[#ffffffcc] hover:bg-[#ffffff11] hover:text-white'
|
|
@@ -75,6 +80,21 @@ const look = computed(() => {
|
|
|
75
80
|
|
|
76
81
|
const fill = computed(() => (variant.value === 'filled' && !props.disabled ? props.color || 'var(--bluevue-primary)' : undefined))
|
|
77
82
|
|
|
83
|
+
const ELEVATION = {
|
|
84
|
+
1: 'bluevue-elevation-1',
|
|
85
|
+
2: 'bluevue-elevation-2',
|
|
86
|
+
3: 'bluevue-elevation-3',
|
|
87
|
+
4: 'bluevue-elevation-4',
|
|
88
|
+
5: 'bluevue-elevation-5',
|
|
89
|
+
} as const
|
|
90
|
+
|
|
91
|
+
const elevationClass = computed(() => {
|
|
92
|
+
if (props.disabled) return ''
|
|
93
|
+
const hasChip = variant.value === 'filled' || variant.value === 'tonal'
|
|
94
|
+
const level = props.elevation ?? (hasChip ? 1 : 0)
|
|
95
|
+
return level ? ELEVATION[level] : ''
|
|
96
|
+
})
|
|
97
|
+
|
|
78
98
|
// The glyph of a text or icon button follows the colour it was given; a filled one draws its
|
|
79
99
|
// contents in white over the fill, and a tonal one in the theme's own text colour.
|
|
80
100
|
const glyphColor = computed(() =>
|
|
@@ -95,6 +115,7 @@ const glyphColor = computed(() =>
|
|
|
95
115
|
variant === 'icon' ? 'rounded-[6px]' : 'rounded-[4px]',
|
|
96
116
|
variant === 'icon' ? '' : PADDING[density],
|
|
97
117
|
block ? 'w-full' : '',
|
|
118
|
+
elevationClass,
|
|
98
119
|
// A button waiting on its own result stays where it is and reads as busy rather than
|
|
99
120
|
// inviting a second press.
|
|
100
121
|
loading ? 'pointer-events-none' : '',
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
ref="labelRef"
|
|
15
15
|
class="block truncate text-start mr-6"
|
|
16
16
|
:title="label"
|
|
17
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
17
|
+
:class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
|
|
18
18
|
>{{ label }}</label>
|
|
19
19
|
</div>
|
|
20
20
|
<div v-else />
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
name="mdi-information-outline"
|
|
33
33
|
:size="16"
|
|
34
34
|
:label="infoTooltip"
|
|
35
|
-
class="
|
|
36
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
35
|
+
class="cursor-help"
|
|
36
|
+
:class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
|
|
37
37
|
/>
|
|
38
38
|
</BlueTooltip>
|
|
39
39
|
<div
|
|
40
40
|
ref="trackRef"
|
|
41
|
-
class="relative flex min-w-0 justify-end overflow-hidden rounded-[6px] bluevue-elevation-1
|
|
41
|
+
class="relative isolate flex min-w-0 justify-end overflow-hidden rounded-[6px] bluevue-elevation-1"
|
|
42
42
|
:class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30 pointer-events-none' : '']"
|
|
43
|
-
:style="{ height: height || (density === 'compact' ? '24px' : '30px') }"
|
|
43
|
+
:style="{ height: height || (density === 'compact' ? '24px' : '30px'), width }"
|
|
44
44
|
>
|
|
45
45
|
<div
|
|
46
46
|
id="border-releif"
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<button
|
|
54
54
|
:disabled="disabled || btn.disabled"
|
|
55
55
|
:title="btn.name"
|
|
56
|
-
class="flex min-w-0 items-center justify-center
|
|
56
|
+
class="flex min-w-0 items-center justify-center font-normal transition-colors duration-200"
|
|
57
57
|
:class="[
|
|
58
58
|
// A group with no room left falls back to the tightest inset whatever the
|
|
59
59
|
// density asks for, since the alternative is cutting into the names.
|
|
@@ -61,6 +61,9 @@
|
|
|
61
61
|
// The active choice is the one worth reading at a glance, so it keeps its full
|
|
62
62
|
// name and the rest give up the room a crowded group needs.
|
|
63
63
|
selected[idx] ? 'shrink-0' : '',
|
|
64
|
+
// A group given a width shares the surplus between its buttons rather than
|
|
65
|
+
// leaving it as dead track behind them.
|
|
66
|
+
width ? 'grow' : '',
|
|
64
67
|
selected[idx] ? 'text-white' : theme === 'dark' ? 'text-[#ffffff99]' : 'text-[#00000066]',
|
|
65
68
|
selected[idx] && type === 'switch' ? 'bluevue-elevation-5' : '',
|
|
66
69
|
disabled || btn.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
|
@@ -86,7 +89,7 @@
|
|
|
86
89
|
<span
|
|
87
90
|
data-bbg-label
|
|
88
91
|
class="block truncate"
|
|
89
|
-
:class="[density === 'compact' ? 'text-[
|
|
92
|
+
:class="[density === 'compact' ? 'text-[13px]' : 'text-base', isWide ? 'max-w-[150px]' : '']"
|
|
90
93
|
>
|
|
91
94
|
{{ btn.name }}
|
|
92
95
|
</span>
|
|
@@ -205,6 +208,12 @@ const props = defineProps<{
|
|
|
205
208
|
type: 'switch' | 'toggle'
|
|
206
209
|
/** Height of the button group container */
|
|
207
210
|
height?: string
|
|
211
|
+
/**
|
|
212
|
+
* Width of the button group container. The buttons share whatever it grants beyond their
|
|
213
|
+
* natural size, so the selected one still reaches the group's edge. It is a starting width
|
|
214
|
+
* rather than a fixed one, so a narrow row can still shrink the group.
|
|
215
|
+
*/
|
|
216
|
+
width?: string
|
|
208
217
|
/** Label text on the left side of the button group */
|
|
209
218
|
label?: string
|
|
210
219
|
/** Optional info tooltip shown via an info icon next to the control. */
|
|
@@ -246,9 +255,9 @@ let longPressFired = false
|
|
|
246
255
|
// The inset each density gives a button name, paired with the total width it costs so the
|
|
247
256
|
// measurements below can reason about the group without reading it back from the DOM.
|
|
248
257
|
const DENSITY_PADDING = {
|
|
249
|
-
compact: { class: 'px-[
|
|
250
|
-
regular: { class: 'px-[
|
|
251
|
-
comfortable: { class: 'px-[
|
|
258
|
+
compact: { class: 'px-[8px]', total: 16 },
|
|
259
|
+
regular: { class: 'px-[12px]', total: 24 },
|
|
260
|
+
comfortable: { class: 'px-[16px]', total: 32 },
|
|
252
261
|
} as const
|
|
253
262
|
|
|
254
263
|
// Once the buttons' natural width would exceed this, cap each label so long names
|
|
@@ -59,8 +59,8 @@ const toggle = (): void => {
|
|
|
59
59
|
name="mdi-information-outline"
|
|
60
60
|
:size="16"
|
|
61
61
|
:label="infoTooltip"
|
|
62
|
-
class="
|
|
63
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
62
|
+
class="cursor-help"
|
|
63
|
+
:class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
|
|
64
64
|
/>
|
|
65
65
|
</BlueTooltip>
|
|
66
66
|
|
|
@@ -9,7 +9,7 @@ import BlueIcon from './BlueIcon.vue'
|
|
|
9
9
|
* with an optional header, a body and an optional footer. Being a native modal, it dims the page
|
|
10
10
|
* itself, traps focus, and closes on Escape and on the backdrop unless it is told not to.
|
|
11
11
|
*/
|
|
12
|
-
const props = defineProps<{
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
13
|
modelValue: boolean
|
|
14
14
|
/** The heading. Without one, and without a header slot, the dialog is only its body. */
|
|
15
15
|
title?: string
|
|
@@ -25,9 +25,30 @@ const props = defineProps<{
|
|
|
25
25
|
persistent?: boolean
|
|
26
26
|
/** Panel width (default '624px'). It never exceeds the viewport. */
|
|
27
27
|
width?: string
|
|
28
|
+
/** Replaces the header's own inset, for a header holding a mark rather than a title row. */
|
|
29
|
+
headerClass?: string
|
|
28
30
|
/** Replaces the body's own inset, for a dialog laying its contents out differently. */
|
|
29
31
|
bodyClass?: string
|
|
30
|
-
|
|
32
|
+
/** Replaces the footer's own inset. */
|
|
33
|
+
footerClass?: string
|
|
34
|
+
/**
|
|
35
|
+
* The rule under the header, on unless it is passed false. A header that reads as part of the
|
|
36
|
+
* body, such as a centred mark above a title, wants none.
|
|
37
|
+
*/
|
|
38
|
+
headerDivider?: boolean
|
|
39
|
+
}>(), {
|
|
40
|
+
// An absent boolean prop is `false` unless a default says otherwise, which would leave every
|
|
41
|
+
// header unruled.
|
|
42
|
+
title: undefined,
|
|
43
|
+
icon: undefined,
|
|
44
|
+
iconColor: undefined,
|
|
45
|
+
subtitle: undefined,
|
|
46
|
+
width: undefined,
|
|
47
|
+
headerClass: undefined,
|
|
48
|
+
bodyClass: undefined,
|
|
49
|
+
footerClass: undefined,
|
|
50
|
+
headerDivider: undefined,
|
|
51
|
+
})
|
|
31
52
|
|
|
32
53
|
const emit = defineEmits<{
|
|
33
54
|
(event: 'update:modelValue', value: boolean): void
|
|
@@ -38,10 +59,18 @@ const dialogRef = ref<HTMLDialogElement | null>(null)
|
|
|
38
59
|
|
|
39
60
|
const hasHeader = computed(() => Boolean(props.title || slots.header))
|
|
40
61
|
|
|
62
|
+
const hasHeaderDivider = computed(() => hasHeader.value && (props.headerDivider ?? true))
|
|
63
|
+
|
|
41
64
|
const sync = (show: boolean): void => {
|
|
42
65
|
const el = dialogRef.value
|
|
43
66
|
if (!el) return
|
|
44
|
-
if (show && !el.open)
|
|
67
|
+
if (show && !el.open) {
|
|
68
|
+
el.showModal()
|
|
69
|
+
// showModal() hands focus to the first control inside, which rings a button nobody asked for.
|
|
70
|
+
// Chromium ignores autofocus on the dialog itself, so the surface has to claim focus back:
|
|
71
|
+
// still inside the trap and still announced, with nothing preselected.
|
|
72
|
+
el.focus()
|
|
73
|
+
}
|
|
45
74
|
if (!show && el.open) el.close()
|
|
46
75
|
}
|
|
47
76
|
|
|
@@ -66,20 +95,23 @@ defineExpose({ close })
|
|
|
66
95
|
</script>
|
|
67
96
|
|
|
68
97
|
<template>
|
|
98
|
+
<!-- tabindex is what lets the surface hold the focus that sync() moves to it on open. -->
|
|
69
99
|
<dialog
|
|
70
100
|
ref="dialogRef"
|
|
101
|
+
tabindex="-1"
|
|
71
102
|
class="bluevue-dialog"
|
|
72
103
|
@close="emit('update:modelValue', false)"
|
|
73
104
|
@cancel="onCancel"
|
|
74
105
|
@click="onBackdropClick"
|
|
75
106
|
>
|
|
76
107
|
<div
|
|
77
|
-
class="bluevue-panel relative max-w-full rounded-lg text-white"
|
|
108
|
+
class="bluevue-panel relative flex min-h-0 max-w-full flex-col rounded-lg text-white"
|
|
78
109
|
:style="{ width: width || '624px' }"
|
|
79
110
|
>
|
|
80
111
|
<header
|
|
81
112
|
v-if="hasHeader"
|
|
82
|
-
class="flex items-start gap-3
|
|
113
|
+
class="flex shrink-0 items-start gap-3"
|
|
114
|
+
:class="headerClass || 'px-5 pt-4 pb-3'"
|
|
83
115
|
>
|
|
84
116
|
<slot name="header">
|
|
85
117
|
<BlueIcon
|
|
@@ -120,13 +152,20 @@ defineExpose({ close })
|
|
|
120
152
|
@click="close"
|
|
121
153
|
/>
|
|
122
154
|
|
|
123
|
-
<div
|
|
155
|
+
<div
|
|
156
|
+
:class="[
|
|
157
|
+
'min-h-0 flex-1 overflow-y-auto',
|
|
158
|
+
bodyClass || 'px-5 py-4',
|
|
159
|
+
hasHeaderDivider ? 'border-t border-[var(--bluevue-hairline)]' : '',
|
|
160
|
+
]"
|
|
161
|
+
>
|
|
124
162
|
<slot />
|
|
125
163
|
</div>
|
|
126
164
|
|
|
127
165
|
<footer
|
|
128
166
|
v-if="slots.footer"
|
|
129
|
-
class="flex items-center justify-between gap-3 border-t border-[
|
|
167
|
+
class="flex shrink-0 items-center justify-between gap-3 border-t border-[var(--bluevue-hairline)]"
|
|
168
|
+
:class="footerClass || 'px-5 py-3'"
|
|
130
169
|
>
|
|
131
170
|
<slot name="footer" />
|
|
132
171
|
</footer>
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
class="flex items-center justify-between bg-transparent cursor-pointer select-none px-3 my-2"
|
|
5
5
|
@click="togglePanel"
|
|
6
6
|
>
|
|
7
|
-
<div
|
|
7
|
+
<div
|
|
8
|
+
class="flex-1 flex items-center"
|
|
9
|
+
:class="props.headerAlign === 'end' ? 'justify-end' : ''"
|
|
10
|
+
>
|
|
8
11
|
<div
|
|
12
|
+
v-if="props.headerAlign !== 'end'"
|
|
9
13
|
class="flex-1 h-px"
|
|
10
14
|
:class="props.theme === 'dark' ? 'bg-[#ffffff22]' : 'bg-[#00000033]'"
|
|
11
15
|
/>
|
|
12
16
|
<span
|
|
13
|
-
class="mx-3 min-w-0 truncate text-
|
|
17
|
+
class="mx-3 min-w-0 truncate text-base font-normal opacity-60"
|
|
14
18
|
:title="title"
|
|
15
19
|
:class="props.theme === 'dark' ? 'text-white' : 'text-black'"
|
|
16
20
|
>{{ title }}</span>
|
|
17
21
|
<div
|
|
22
|
+
v-if="props.headerAlign !== 'end'"
|
|
18
23
|
class="flex-1 h-px"
|
|
19
24
|
:class="props.theme === 'dark' ? 'bg-[#ffffff11]' : 'bg-[#00000033]'"
|
|
20
25
|
/>
|
|
@@ -27,13 +32,28 @@
|
|
|
27
32
|
]"
|
|
28
33
|
/>
|
|
29
34
|
</div>
|
|
30
|
-
<transition
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
<transition
|
|
36
|
+
name="bluevue-expand"
|
|
37
|
+
@enter="openTo"
|
|
38
|
+
@leave="closeFrom"
|
|
39
|
+
@after-enter="releaseHeight"
|
|
40
|
+
@after-leave="releaseHeight"
|
|
41
|
+
@enter-cancelled="releaseHeight"
|
|
42
|
+
@leave-cancelled="releaseHeight"
|
|
43
|
+
>
|
|
44
|
+
<!-- The animated box holds no inset of its own. max-height cannot take an element below its
|
|
45
|
+
own padding, so padding here would strand the collapse at that height for the rest of
|
|
46
|
+
the run, and a margin would outlive it altogether. -->
|
|
47
|
+
<div v-show="isOpen">
|
|
48
|
+
<div
|
|
49
|
+
:class="[
|
|
50
|
+
'flex flex-col gap-[15px]',
|
|
51
|
+
props.bodyClass || 'px-13 pt-4 pb-8',
|
|
52
|
+
props.theme === 'dark' ? 'border-[#555555]' : 'border-[#dddddd]',
|
|
53
|
+
]"
|
|
54
|
+
>
|
|
55
|
+
<slot />
|
|
56
|
+
</div>
|
|
37
57
|
</div>
|
|
38
58
|
</transition>
|
|
39
59
|
</div>
|
|
@@ -47,6 +67,17 @@ const props = defineProps<{
|
|
|
47
67
|
title: string
|
|
48
68
|
/** Initial open state */
|
|
49
69
|
expanded?: boolean
|
|
70
|
+
/**
|
|
71
|
+
* Replaces the body's own inset, for a panel nested inside one that already insets its content.
|
|
72
|
+
* The column gap between the panel's children is kept either way. Use padding rather than
|
|
73
|
+
* margin: the body collapses by animating its own height, which a margin would not follow.
|
|
74
|
+
*/
|
|
75
|
+
bodyClass?: string
|
|
76
|
+
/**
|
|
77
|
+
* 'full' (default) rules the title off across the panel; 'end' drops the rules and tucks the
|
|
78
|
+
* title against the right edge, for a secondary panel nested inside another one.
|
|
79
|
+
*/
|
|
80
|
+
headerAlign?: 'full' | 'end'
|
|
50
81
|
/** 'light' or 'dark' theme */
|
|
51
82
|
theme?: 'light' | 'dark'
|
|
52
83
|
}>()
|
|
@@ -57,6 +88,29 @@ const emit = defineEmits<{
|
|
|
57
88
|
|
|
58
89
|
const isOpen = ref(props.expanded ?? true)
|
|
59
90
|
|
|
91
|
+
// max-height cannot animate to or from a keyword, so every open and close is driven from the
|
|
92
|
+
// panel's own measured height. A fixed ceiling instead would either clip a panel taller than it
|
|
93
|
+
// or open a short one in a fraction of the duration and then sit still for the rest.
|
|
94
|
+
const openTo = (el: Element): void => {
|
|
95
|
+
const panel = el as HTMLElement
|
|
96
|
+
panel.style.maxHeight = '0px'
|
|
97
|
+
const target = panel.scrollHeight
|
|
98
|
+
panel.getBoundingClientRect()
|
|
99
|
+
panel.style.maxHeight = `${target}px`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const closeFrom = (el: Element): void => {
|
|
103
|
+
const panel = el as HTMLElement
|
|
104
|
+
panel.style.maxHeight = `${panel.scrollHeight}px`
|
|
105
|
+
panel.getBoundingClientRect()
|
|
106
|
+
panel.style.maxHeight = '0px'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Left in place, the cap would keep the panel from growing with whatever the slot renders next.
|
|
110
|
+
const releaseHeight = (el: Element): void => {
|
|
111
|
+
;(el as HTMLElement).style.maxHeight = ''
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
const togglePanel = (): void => {
|
|
61
115
|
isOpen.value = !isOpen.value
|
|
62
116
|
emit('update:expanded', isOpen.value)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header
|
|
3
|
+
class="flex w-full items-center justify-between rounded-t-[8px]"
|
|
4
|
+
:class="isDark ? 'text-white' : 'text-black'"
|
|
5
|
+
:style="{ minHeight: height || '56px', backgroundColor: background || defaultBackground }"
|
|
6
|
+
>
|
|
7
|
+
<!-- The rule runs under the whole cluster rather than under the selector alone, so the subject
|
|
8
|
+
of the panel reads as one field however many controls make it up. -->
|
|
9
|
+
<div
|
|
10
|
+
class="flex min-w-0 items-center gap-3 self-stretch border-b border-solid pl-4"
|
|
11
|
+
:class="[leadingWidth ? '' : 'flex-1', isDark ? 'border-[#ffffff88]' : 'border-[#00000066]']"
|
|
12
|
+
:style="leadingWidth ? { width: leadingWidth, maxWidth: '100%' } : undefined"
|
|
13
|
+
>
|
|
14
|
+
<slot name="leading" />
|
|
15
|
+
</div>
|
|
16
|
+
<div class="flex shrink-0 items-center gap-2 pr-4">
|
|
17
|
+
<slot />
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import { computed } from 'vue'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The bar a panel opens with: what the panel is about on the left, and the controls that act on it
|
|
27
|
+
* on the right. It only lays the two out, so the subject can be a BlueHeaderSelector, a title, or
|
|
28
|
+
* anything else the panel names itself by.
|
|
29
|
+
*/
|
|
30
|
+
const props = defineProps<{
|
|
31
|
+
/** How tall the bar may not go under (default '56px'). */
|
|
32
|
+
height?: string
|
|
33
|
+
/** Caps the leading cluster, so a long name cannot push the controls off the bar. */
|
|
34
|
+
leadingWidth?: string
|
|
35
|
+
/** Overrides the bar's own fill. */
|
|
36
|
+
background?: string
|
|
37
|
+
theme?: 'light' | 'dark'
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const isDark = computed(() => props.theme !== 'light')
|
|
41
|
+
const defaultBackground = computed(() => (isDark.value ? '#15151577' : '#00000008'))
|
|
42
|
+
</script>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<BlueMenu
|
|
3
|
+
:model-value="isOpen"
|
|
4
|
+
:items="items"
|
|
5
|
+
@update:model-value="(value: boolean) => (isOpen = value)"
|
|
6
|
+
>
|
|
7
|
+
<template #activator="{ props: activatorProps }">
|
|
8
|
+
<button
|
|
9
|
+
v-bind="activatorProps"
|
|
10
|
+
type="button"
|
|
11
|
+
:disabled="disabled"
|
|
12
|
+
:title="tooltip"
|
|
13
|
+
:aria-label="tooltip"
|
|
14
|
+
class="flex shrink-0 items-center justify-center rounded-[6px] transition-colors"
|
|
15
|
+
:class="[
|
|
16
|
+
disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
|
17
|
+
isDark ? 'text-[#ffffffcc] hover:text-white' : 'text-[#000000cc] hover:text-black',
|
|
18
|
+
]"
|
|
19
|
+
:style="{ width: `${size}px`, height: `${size}px` }"
|
|
20
|
+
>
|
|
21
|
+
<span
|
|
22
|
+
class="mdi leading-none"
|
|
23
|
+
:class="isOpen ? 'mdi-menu-open' : 'mdi-menu-close'"
|
|
24
|
+
:style="{ fontSize: `${size}px` }"
|
|
25
|
+
/>
|
|
26
|
+
</button>
|
|
27
|
+
</template>
|
|
28
|
+
</BlueMenu>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
import { computed, ref } from 'vue'
|
|
33
|
+
|
|
34
|
+
import BlueMenu, { type BlueMenuItem } from './BlueMenu.vue'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The actions a header offers, behind the glyph that turns as they open. Sized for a header rather
|
|
38
|
+
* than for a row, which is why it is not an icon BlueButton.
|
|
39
|
+
*/
|
|
40
|
+
const props = defineProps<{
|
|
41
|
+
items: BlueMenuItem[]
|
|
42
|
+
/** Edge of the square the glyph fills, in pixels (default 24). */
|
|
43
|
+
iconSize?: number
|
|
44
|
+
/** Hover text, and the name assistive technology reads. */
|
|
45
|
+
tooltip?: string
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
theme?: 'light' | 'dark'
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const isOpen = ref(false)
|
|
51
|
+
const isDark = computed(() => props.theme !== 'light')
|
|
52
|
+
const size = computed(() => props.iconSize ?? 24)
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="anchorRef"
|
|
4
|
+
class="min-w-0 flex-1"
|
|
5
|
+
>
|
|
6
|
+
<button
|
|
7
|
+
v-bind="activatorProps"
|
|
8
|
+
type="button"
|
|
9
|
+
:disabled="disabled"
|
|
10
|
+
class="group relative flex h-[56px] w-full min-w-0 flex-col justify-center bg-transparent pr-8 pl-4 text-left transition-colors"
|
|
11
|
+
:class="[
|
|
12
|
+
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
|
|
13
|
+
isDark ? 'hover:bg-[#ffffff08]' : 'hover:bg-[#00000008]',
|
|
14
|
+
]"
|
|
15
|
+
>
|
|
16
|
+
<span
|
|
17
|
+
v-if="label"
|
|
18
|
+
class="block truncate text-[12px] leading-none"
|
|
19
|
+
:class="isDark ? 'text-[#ffffffb3]' : 'text-[#00000099]'"
|
|
20
|
+
>{{ label }}</span>
|
|
21
|
+
<span
|
|
22
|
+
class="mt-[6px] flex min-w-0 items-center gap-1 text-[16px] leading-none"
|
|
23
|
+
:class="isDark ? 'text-white' : 'text-black'"
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
v-if="selectedItem?.icon"
|
|
27
|
+
class="mdi shrink-0 text-[16px] leading-none opacity-70"
|
|
28
|
+
:class="selectedItem.icon"
|
|
29
|
+
/>
|
|
30
|
+
<span class="truncate">{{ selectedItem?.name ?? placeholder }}</span>
|
|
31
|
+
</span>
|
|
32
|
+
<span
|
|
33
|
+
class="mdi mdi-menu-down absolute right-2 top-1/2 -translate-y-1/2 text-[24px] leading-none transition-transform"
|
|
34
|
+
:class="[isDark ? 'text-[#ffffffb3]' : 'text-[#00000099]', isOpen ? 'rotate-180' : '']"
|
|
35
|
+
/>
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
:id="popoverId"
|
|
40
|
+
ref="popoverRef"
|
|
41
|
+
popover
|
|
42
|
+
class="bluevue-popover"
|
|
43
|
+
:style="floatingStyles"
|
|
44
|
+
@toggle="onToggle"
|
|
45
|
+
>
|
|
46
|
+
<ul
|
|
47
|
+
class="bluevue-elevation-5 max-h-[60vh] overflow-y-auto rounded-[4px] border border-[#FFFFFF22]"
|
|
48
|
+
:class="[
|
|
49
|
+
isDark ? 'bg-[var(--bluevue-surface)]' : 'bg-white',
|
|
50
|
+
isPositioned ? 'opacity-100' : 'opacity-0',
|
|
51
|
+
]"
|
|
52
|
+
>
|
|
53
|
+
<li
|
|
54
|
+
v-for="item in items"
|
|
55
|
+
:key="String(item.value ?? item.name)"
|
|
56
|
+
>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
:disabled="item.disabled"
|
|
60
|
+
class="flex w-full items-start gap-2 px-4 py-2 text-left"
|
|
61
|
+
:class="[
|
|
62
|
+
isDark ? 'text-white' : 'text-black',
|
|
63
|
+
item.disabled
|
|
64
|
+
? 'cursor-not-allowed opacity-30'
|
|
65
|
+
: isDark ? 'cursor-pointer hover:bg-[#ffffff11]' : 'cursor-pointer hover:bg-gray-100',
|
|
66
|
+
]"
|
|
67
|
+
@click="selectItem(item)"
|
|
68
|
+
>
|
|
69
|
+
<span
|
|
70
|
+
v-if="item.icon"
|
|
71
|
+
class="mdi mt-[2px] shrink-0 text-[16px] leading-none opacity-70"
|
|
72
|
+
:class="item.icon"
|
|
73
|
+
/>
|
|
74
|
+
<span class="min-w-0">
|
|
75
|
+
<span class="block truncate text-[14px] leading-snug">{{ item.name }}</span>
|
|
76
|
+
<span
|
|
77
|
+
v-if="item.subtitle"
|
|
78
|
+
class="block truncate text-[11px]"
|
|
79
|
+
:class="isDark ? 'text-[#ffffff66]' : 'text-[#00000066]'"
|
|
80
|
+
>{{ item.subtitle }}</span>
|
|
81
|
+
</span>
|
|
82
|
+
</button>
|
|
83
|
+
</li>
|
|
84
|
+
</ul>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<script setup lang="ts">
|
|
90
|
+
import { computed } from 'vue'
|
|
91
|
+
|
|
92
|
+
import { useBluePopover } from '../composables/useBluePopover'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One choice in the header selector.
|
|
96
|
+
*/
|
|
97
|
+
export interface BlueHeaderSelectorItem {
|
|
98
|
+
/** What the row reads. */
|
|
99
|
+
name: string
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
value?: any
|
|
102
|
+
/** An mdi class marking the row, such as one still being looked for. */
|
|
103
|
+
icon?: string
|
|
104
|
+
/** A second line under the name, for what the row is rather than what it is called. */
|
|
105
|
+
subtitle?: string
|
|
106
|
+
disabled?: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The subject a header is about, named above the value the way a filled form field is, so the
|
|
111
|
+
* heading and the choice occupy one control instead of a label and a dropdown side by side.
|
|
112
|
+
*/
|
|
113
|
+
const props = defineProps<{
|
|
114
|
+
items: BlueHeaderSelectorItem[]
|
|
115
|
+
/** The name above the value. */
|
|
116
|
+
label?: string
|
|
117
|
+
/** Stands in for the value until something is chosen. */
|
|
118
|
+
placeholder?: string
|
|
119
|
+
disabled?: boolean
|
|
120
|
+
theme?: 'light' | 'dark'
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
122
|
+
modelValue?: any
|
|
123
|
+
}>()
|
|
124
|
+
|
|
125
|
+
const emit = defineEmits<{
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
(e: 'update:modelValue', value: any): void
|
|
128
|
+
}>()
|
|
129
|
+
|
|
130
|
+
const {
|
|
131
|
+
anchorRef,
|
|
132
|
+
popoverRef,
|
|
133
|
+
popoverId,
|
|
134
|
+
activatorProps,
|
|
135
|
+
isOpen,
|
|
136
|
+
isPositioned,
|
|
137
|
+
floatingStyles,
|
|
138
|
+
hide,
|
|
139
|
+
onToggle,
|
|
140
|
+
} = useBluePopover({ matchAnchorWidth: true })
|
|
141
|
+
|
|
142
|
+
const isDark = computed(() => props.theme !== 'light')
|
|
143
|
+
|
|
144
|
+
const selectedItem = computed(() =>
|
|
145
|
+
props.items.find((item) => (item.value ?? item.name) === props.modelValue)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const selectItem = (item: BlueHeaderSelectorItem): void => {
|
|
149
|
+
if (item.disabled) return
|
|
150
|
+
hide()
|
|
151
|
+
emit('update:modelValue', item.value ?? item.name)
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
:for="name"
|
|
12
12
|
class="block truncate text-start mr-6"
|
|
13
13
|
:title="label"
|
|
14
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
14
|
+
:class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
|
|
15
15
|
>
|
|
16
16
|
{{ label }}
|
|
17
17
|
</label>
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
name="mdi-information-outline"
|
|
30
30
|
:size="16"
|
|
31
31
|
:label="infoTooltip"
|
|
32
|
-
class="
|
|
33
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
32
|
+
class="cursor-help"
|
|
33
|
+
:class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
|
|
34
34
|
/>
|
|
35
35
|
</BlueTooltip>
|
|
36
36
|
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
name="mdi-information-outline"
|
|
29
29
|
:size="16"
|
|
30
30
|
:label="infoTooltip"
|
|
31
|
-
class="
|
|
32
|
-
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
31
|
+
class="cursor-help"
|
|
32
|
+
:class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
|
|
33
33
|
/>
|
|
34
34
|
</BlueTooltip>
|
|
35
35
|
|