@bluerobotics/bluevue 0.1.0
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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/bluevue.js +1176 -0
- package/dist/components/BlueButtonGroup.vue.d.ts +101 -0
- package/dist/components/BlueExpansiblePanel.vue.d.ts +38 -0
- package/dist/components/BlueInput.vue.d.ts +70 -0
- package/dist/components/BlueLoadingDialog.vue.d.ts +27 -0
- package/dist/components/BlueMenu.vue.d.ts +57 -0
- package/dist/components/BluePromptDialog.vue.d.ts +37 -0
- package/dist/components/BlueSelect.vue.d.ts +76 -0
- package/dist/components/BlueSlider.vue.d.ts +51 -0
- package/dist/components/BlueSwitch.vue.d.ts +39 -0
- package/dist/composables/useBluePopover.d.ts +38 -0
- package/dist/index.d.ts +10 -0
- package/dist/style.css +2 -0
- package/package.json +64 -0
- package/src/assets/br-logo-white.svg +1 -0
- package/src/components/BlueButtonGroup.vue +382 -0
- package/src/components/BlueExpansiblePanel.vue +72 -0
- package/src/components/BlueInput.vue +247 -0
- package/src/components/BlueLoadingDialog.vue +76 -0
- package/src/components/BlueMenu.vue +119 -0
- package/src/components/BluePromptDialog.vue +151 -0
- package/src/components/BlueSelect.vue +236 -0
- package/src/components/BlueSlider.vue +464 -0
- package/src/components/BlueSwitch.vue +117 -0
- package/src/composables/useBluePopover.ts +153 -0
- package/src/index.ts +11 -0
- package/src/styles/bluevue.css +124 -0
- package/src/styles/bundled.css +9 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<template>
|
|
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. -->
|
|
6
|
+
<div
|
|
7
|
+
v-if="label"
|
|
8
|
+
class="min-w-0 max-w-[45%] shrink-0"
|
|
9
|
+
>
|
|
10
|
+
<label
|
|
11
|
+
:for="name"
|
|
12
|
+
class="block truncate text-start mr-6"
|
|
13
|
+
:title="label"
|
|
14
|
+
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
15
|
+
>
|
|
16
|
+
{{ label }}
|
|
17
|
+
</label>
|
|
18
|
+
</div>
|
|
19
|
+
<div v-else />
|
|
20
|
+
<slot name="insetElement" />
|
|
21
|
+
|
|
22
|
+
<div
|
|
23
|
+
v-if="infoTooltip"
|
|
24
|
+
class="relative group inline-flex items-center shrink-0 mr-2 ml-auto"
|
|
25
|
+
>
|
|
26
|
+
<span
|
|
27
|
+
class="mdi mdi-information-outline text-[16px] opacity-60 cursor-help"
|
|
28
|
+
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
29
|
+
/>
|
|
30
|
+
<div
|
|
31
|
+
class="absolute bottom-full mb-1 right-0 hidden group-hover:block w-max max-w-[280px] px-2 py-1 text-xs rounded bg-gray-700 text-white z-[1000]"
|
|
32
|
+
>
|
|
33
|
+
{{ infoTooltip }}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="flex flex-col items-end min-w-0 shrink-[1000]">
|
|
38
|
+
<!-- The inset shadow darkens a band along the top edge, which the eye takes for the rim of
|
|
39
|
+
the well rather than part of its floor. That drops the centre the value looks centred
|
|
40
|
+
against by part of that band, which the top padding pushes the content down to meet. It
|
|
41
|
+
is set inline because a host reset can beat the equivalent utility class, as Vuetify's
|
|
42
|
+
does. -->
|
|
43
|
+
<div
|
|
44
|
+
class="relative flex items-center rounded-[6px] bluevue-inset-1 min-w-[90px] max-w-full"
|
|
45
|
+
:class="[
|
|
46
|
+
disabled ? 'opacity-30 pointer-events-none' : '',
|
|
47
|
+
hasError ? 'border-2 border-solid border-[var(--bluevue-error)]' : '',
|
|
48
|
+
]"
|
|
49
|
+
:style="{
|
|
50
|
+
height: height || '30px',
|
|
51
|
+
width: width || 'auto',
|
|
52
|
+
backgroundColor: fieldBackground,
|
|
53
|
+
paddingTop: '2px',
|
|
54
|
+
}"
|
|
55
|
+
>
|
|
56
|
+
<!-- Always a text input, even for numbers: a native number input reports a half-typed
|
|
57
|
+
value ('-', '-27.') as an empty string, which would wipe the model mid-keystroke. -->
|
|
58
|
+
<input
|
|
59
|
+
:id="name"
|
|
60
|
+
ref="inputRef"
|
|
61
|
+
:name="name"
|
|
62
|
+
type="text"
|
|
63
|
+
:value="draft"
|
|
64
|
+
:placeholder="placeholder"
|
|
65
|
+
:disabled="disabled"
|
|
66
|
+
:autofocus="autofocus"
|
|
67
|
+
:inputmode="type === 'number' ? 'decimal' : undefined"
|
|
68
|
+
class="w-full min-w-0 bg-transparent pl-4 text-sm font-medium outline-none"
|
|
69
|
+
:class="[
|
|
70
|
+
suffix ? 'pr-1' : 'pr-4',
|
|
71
|
+
theme === 'dark' ? 'text-white placeholder:text-[#ffffff44]' : 'text-black placeholder:text-[#00000055]',
|
|
72
|
+
]"
|
|
73
|
+
@input="onInput"
|
|
74
|
+
@keydown.up.prevent="nudge(1)"
|
|
75
|
+
@keydown.down.prevent="nudge(-1)"
|
|
76
|
+
@focus="focused = true"
|
|
77
|
+
@blur="onBlur"
|
|
78
|
+
@keydown.enter="normalize"
|
|
79
|
+
>
|
|
80
|
+
<span
|
|
81
|
+
v-if="suffix"
|
|
82
|
+
class="shrink-0 pr-4 pl-1 text-sm select-none"
|
|
83
|
+
:class="[
|
|
84
|
+
theme === 'dark' ? 'text-[#ffffff66]' : 'text-[#00000066]',
|
|
85
|
+
suffixRidesHigh ? 'translate-y-[0.22em]' : '',
|
|
86
|
+
]"
|
|
87
|
+
>
|
|
88
|
+
{{ suffix }}
|
|
89
|
+
</span>
|
|
90
|
+
<!-- Focus reads as a hairline in the accent blue rather than a browser outline, so a
|
|
91
|
+
focused field sits at the same weight as a selected button group segment. It rides
|
|
92
|
+
on its own layer so the border costs the field no height. -->
|
|
93
|
+
<div
|
|
94
|
+
class="pointer-events-none absolute inset-0 rounded-[6px]"
|
|
95
|
+
:style="{ border: `1px solid ${focused ? 'var(--bluevue-accent)' : 'transparent'}` }"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div
|
|
99
|
+
v-if="hasError"
|
|
100
|
+
class="text-[14px] text-[var(--bluevue-error)]"
|
|
101
|
+
>
|
|
102
|
+
{{ (errorMessages?.[0]) || '' }}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<script setup lang="ts">
|
|
109
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
110
|
+
|
|
111
|
+
const props = defineProps<{
|
|
112
|
+
/**
|
|
113
|
+
* Model value for v-model. A 'number' field emits numbers and never emits an empty value:
|
|
114
|
+
* clearing it leaves the last number in place until a new one is typed.
|
|
115
|
+
*/
|
|
116
|
+
modelValue: string | number | null
|
|
117
|
+
/** Label on the left */
|
|
118
|
+
label?: string
|
|
119
|
+
/** Name and id for the input, tying the label to it */
|
|
120
|
+
name?: string
|
|
121
|
+
/** 'text' (default) or 'number' */
|
|
122
|
+
type?: 'text' | 'number'
|
|
123
|
+
/** Unit or short hint shown inside the field, after the value */
|
|
124
|
+
suffix?: string
|
|
125
|
+
/** Placeholder shown while the field is empty */
|
|
126
|
+
placeholder?: string
|
|
127
|
+
/**
|
|
128
|
+
* Take focus when the field appears. The attribute is what a dialog opening around the field
|
|
129
|
+
* honours, and the call below covers a field mounted into something already on screen.
|
|
130
|
+
*/
|
|
131
|
+
autofocus?: boolean
|
|
132
|
+
/** Disable the field */
|
|
133
|
+
disabled?: boolean
|
|
134
|
+
/** light or dark theme */
|
|
135
|
+
theme?: 'light' | 'dark'
|
|
136
|
+
/** Control height (default '30px') */
|
|
137
|
+
height?: string
|
|
138
|
+
/** Control width (default fits the content) */
|
|
139
|
+
width?: string
|
|
140
|
+
/** Optional info tooltip shown via an info icon next to the control. */
|
|
141
|
+
infoTooltip?: string
|
|
142
|
+
/** Bounds and increment, forwarded to a number input */
|
|
143
|
+
min?: number
|
|
144
|
+
max?: number
|
|
145
|
+
step?: number
|
|
146
|
+
/** Error messages, used to give feedback from validators */
|
|
147
|
+
errorMessages?: string[]
|
|
148
|
+
}>()
|
|
149
|
+
|
|
150
|
+
const emit = defineEmits<{
|
|
151
|
+
(e: 'update:modelValue', value: string | number | null): void
|
|
152
|
+
}>()
|
|
153
|
+
|
|
154
|
+
const hasError = computed(() => props.errorMessages && props.errorMessages.length > 0)
|
|
155
|
+
|
|
156
|
+
// Degree and its relatives are cut at cap height, so on the baseline the value sits on their ink
|
|
157
|
+
// lands near the top of the field. That is right against a number ('27°') but reads as floating
|
|
158
|
+
// when the mark stands alone at the far edge, so it drops to the optical centre. Word suffixes
|
|
159
|
+
// keep the shared baseline, which is where they belong.
|
|
160
|
+
const suffixRidesHigh = computed(() => /^[°′″'"]+$/.test(props.suffix ?? ''))
|
|
161
|
+
|
|
162
|
+
// Applied inline so it survives a host stylesheet whose reset is unlayered, as Vuetify's is:
|
|
163
|
+
// utilities sit in a cascade layer, and unlayered rules win over layered ones outright.
|
|
164
|
+
const fieldBackground = computed(() => (props.theme === 'dark' ? '#FFFFFF11' : '#00000011'))
|
|
165
|
+
|
|
166
|
+
// Everything a number can look like on the way to being one, including the stages that are
|
|
167
|
+
// not a number yet: '', '-', '-27.'.
|
|
168
|
+
const PARTIAL_NUMBER = /^-?\d*\.?\d*$/
|
|
169
|
+
|
|
170
|
+
const format = (value: string | number | null): string =>
|
|
171
|
+
value === null || value === undefined ? '' : String(value)
|
|
172
|
+
|
|
173
|
+
// What the field shows, kept apart from the model so a half-typed number stays on screen
|
|
174
|
+
// instead of being rewritten by the parent on every keystroke.
|
|
175
|
+
const draft = ref<string>(format(props.modelValue))
|
|
176
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
177
|
+
const focused = ref(false)
|
|
178
|
+
|
|
179
|
+
onMounted(() => {
|
|
180
|
+
if (props.autofocus) inputRef.value?.focus()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const parse = (raw: string): string | number | null => {
|
|
184
|
+
if (props.type !== 'number') return raw
|
|
185
|
+
if (raw.trim() === '') return null
|
|
186
|
+
const value = Number(raw)
|
|
187
|
+
return Number.isFinite(value) ? value : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const clamp = (value: number): number => {
|
|
191
|
+
const low = props.min !== undefined ? Math.max(value, props.min) : value
|
|
192
|
+
return props.max !== undefined ? Math.min(low, props.max) : low
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onInput = (event: Event): void => {
|
|
196
|
+
const input = event.target as HTMLInputElement
|
|
197
|
+
if (props.type === 'number' && !PARTIAL_NUMBER.test(input.value)) {
|
|
198
|
+
// Nothing a number could grow into, so the keystroke never happened.
|
|
199
|
+
input.value = draft.value
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
draft.value = input.value
|
|
203
|
+
const parsed = parse(draft.value)
|
|
204
|
+
// An emptied or half-typed number field is a value being rewritten, not a value of none, so
|
|
205
|
+
// the model keeps the last real number until a new one is typed. Blur puts it back on screen.
|
|
206
|
+
if (parsed === null && props.type === 'number') return
|
|
207
|
+
emit('update:modelValue', parsed)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Arrow keys walk a number field by its step, the way the spinners this control hides would.
|
|
211
|
+
const nudge = (direction: number): void => {
|
|
212
|
+
if (props.type !== 'number' || props.disabled) return
|
|
213
|
+
const step = props.step ?? 1
|
|
214
|
+
const current = typeof props.modelValue === 'number' ? props.modelValue : Number(draft.value) || 0
|
|
215
|
+
const decimals = (String(step).split('.')[1] ?? '').length
|
|
216
|
+
const next = clamp(Number((current + direction * step).toFixed(decimals)))
|
|
217
|
+
draft.value = String(next)
|
|
218
|
+
emit('update:modelValue', next)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// On the way out, show the value the way the model holds it: '-27.5630' becomes '-27.563',
|
|
222
|
+
// anything that never parsed goes back to the last good value, and a number outside its
|
|
223
|
+
// bounds is pulled back in rather than silently applied.
|
|
224
|
+
const normalize = (): void => {
|
|
225
|
+
if (props.type === 'number' && typeof props.modelValue === 'number') {
|
|
226
|
+
const bounded = clamp(props.modelValue)
|
|
227
|
+
if (bounded !== props.modelValue) {
|
|
228
|
+
emit('update:modelValue', bounded)
|
|
229
|
+
draft.value = String(bounded)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
draft.value = format(props.modelValue)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const onBlur = (): void => {
|
|
237
|
+
focused.value = false
|
|
238
|
+
normalize()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
watch(
|
|
242
|
+
() => props.modelValue,
|
|
243
|
+
(value) => {
|
|
244
|
+
if (parse(draft.value) !== value) draft.value = format(value)
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
</script>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
import logo from '../assets/br-logo-white.svg'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A blocking overlay for an operation that is under way, such as a vehicle restarting. It is up
|
|
8
|
+
* for exactly as long as `modelValue` says it is, and unless it is dismissible there is no way
|
|
9
|
+
* out of it.
|
|
10
|
+
*/
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
modelValue: boolean
|
|
13
|
+
/** What the operation is, shown under the propeller. */
|
|
14
|
+
message?: string
|
|
15
|
+
/** Offers a close button and lets Escape through, for a wait the user may walk away from. */
|
|
16
|
+
dismissible?: boolean
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
(event: 'update:modelValue', value: boolean): void
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const dialogRef = ref<HTMLDialogElement | null>(null)
|
|
24
|
+
|
|
25
|
+
const sync = (show: boolean): void => {
|
|
26
|
+
const el = dialogRef.value
|
|
27
|
+
if (!el) return
|
|
28
|
+
if (show && !el.open) el.showModal()
|
|
29
|
+
if (!show && el.open) el.close()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const close = (): void => emit('update:modelValue', false)
|
|
33
|
+
|
|
34
|
+
// Escape closes a dismissible overlay through the same path as the button, so the caller's
|
|
35
|
+
// state follows the dialog instead of thinking it is still up.
|
|
36
|
+
const onCancel = (event: Event): void => {
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
if (props.dismissible) close()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
onMounted(() => sync(props.modelValue))
|
|
42
|
+
watch(() => props.modelValue, sync)
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<!-- Escape is swallowed unless the wait is dismissible: an operation the user cannot
|
|
47
|
+
interrupt must not end up running under a live page. -->
|
|
48
|
+
<dialog
|
|
49
|
+
ref="dialogRef"
|
|
50
|
+
class="bluevue-dialog"
|
|
51
|
+
@cancel="onCancel"
|
|
52
|
+
>
|
|
53
|
+
<div class="bluevue-panel relative flex w-[320px] flex-col items-center justify-center rounded-lg px-6 pt-7 pb-5">
|
|
54
|
+
<button
|
|
55
|
+
v-if="dismissible"
|
|
56
|
+
type="button"
|
|
57
|
+
class="absolute right-3 top-3 cursor-pointer text-[#ffffffaa] transition-colors hover:text-white"
|
|
58
|
+
title="Close"
|
|
59
|
+
@click="close"
|
|
60
|
+
>
|
|
61
|
+
<span class="mdi mdi-close text-[20px] leading-none" />
|
|
62
|
+
</button>
|
|
63
|
+
<img
|
|
64
|
+
:src="logo"
|
|
65
|
+
alt=""
|
|
66
|
+
class="bluevue-loading__icon mb-6 h-[100px] w-[100px]"
|
|
67
|
+
>
|
|
68
|
+
<span
|
|
69
|
+
v-if="message"
|
|
70
|
+
class="mt-[15px] text-center text-base text-white"
|
|
71
|
+
>
|
|
72
|
+
{{ message }}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
</dialog>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { useBluePopover } from '../composables/useBluePopover'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A dropdown of actions, opened either from the activator slot or, with `target`, at a pointer
|
|
8
|
+
* position, which is what a right-click or a long press wants. Pass `target` to place it at a
|
|
9
|
+
* point; leave it out and the activator slot decides where it hangs.
|
|
10
|
+
*/
|
|
11
|
+
export interface BlueMenuItem {
|
|
12
|
+
title: string
|
|
13
|
+
icon: string
|
|
14
|
+
/** Renders in the warning colour, for the items that remove something. */
|
|
15
|
+
danger?: boolean
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
/** Shown on hover, to say why an item is disabled. */
|
|
18
|
+
hint?: string
|
|
19
|
+
action: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
modelValue: boolean
|
|
24
|
+
items: BlueMenuItem[]
|
|
25
|
+
target?: [number, number]
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{ (event: 'update:modelValue', value: boolean): void }>()
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
anchorRef,
|
|
32
|
+
popoverRef,
|
|
33
|
+
popoverId,
|
|
34
|
+
activatorProps,
|
|
35
|
+
isOpen,
|
|
36
|
+
isPositioned,
|
|
37
|
+
floatingStyles,
|
|
38
|
+
show,
|
|
39
|
+
hide,
|
|
40
|
+
onToggle,
|
|
41
|
+
} = useBluePopover()
|
|
42
|
+
|
|
43
|
+
// The activator opens the menu through the browser, so the model follows the menu as well as
|
|
44
|
+
// driving it: this keeps the two in step whichever of them moved first.
|
|
45
|
+
watch(isOpen, (open) => emit('update:modelValue', open))
|
|
46
|
+
|
|
47
|
+
watch(
|
|
48
|
+
() => [props.modelValue, props.target] as const,
|
|
49
|
+
([open, target]) => {
|
|
50
|
+
if (open) show(target)
|
|
51
|
+
else hide()
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true }
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
function runItem(item: BlueMenuItem): void {
|
|
57
|
+
hide()
|
|
58
|
+
item.action()
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<span
|
|
64
|
+
v-if="!target"
|
|
65
|
+
ref="anchorRef"
|
|
66
|
+
class="inline-flex"
|
|
67
|
+
>
|
|
68
|
+
<slot
|
|
69
|
+
name="activator"
|
|
70
|
+
:props="activatorProps"
|
|
71
|
+
/>
|
|
72
|
+
</span>
|
|
73
|
+
|
|
74
|
+
<div
|
|
75
|
+
:id="popoverId"
|
|
76
|
+
ref="popoverRef"
|
|
77
|
+
popover
|
|
78
|
+
class="bluevue-popover"
|
|
79
|
+
:style="floatingStyles"
|
|
80
|
+
@toggle="onToggle"
|
|
81
|
+
>
|
|
82
|
+
<ul
|
|
83
|
+
class="bluevue-elevation-5 min-w-[220px] max-h-[60vh] overflow-y-auto rounded-[4px] border border-[#ffffff1a] bg-[#2d2d2d]"
|
|
84
|
+
:class="isPositioned ? 'opacity-100' : 'opacity-0'"
|
|
85
|
+
>
|
|
86
|
+
<li
|
|
87
|
+
v-for="(item, index) in items"
|
|
88
|
+
:key="item.title"
|
|
89
|
+
>
|
|
90
|
+
<div
|
|
91
|
+
v-if="index"
|
|
92
|
+
class="h-px bg-[#ffffff0d]"
|
|
93
|
+
/>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
:disabled="item.disabled"
|
|
97
|
+
class="flex w-full items-start gap-3 px-4 py-2 text-left"
|
|
98
|
+
:class="[
|
|
99
|
+
item.danger ? 'text-[#ff6b6b]' : 'text-white',
|
|
100
|
+
item.disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer hover:bg-[#ffffff11]',
|
|
101
|
+
]"
|
|
102
|
+
@click="runItem(item)"
|
|
103
|
+
>
|
|
104
|
+
<span
|
|
105
|
+
class="mdi shrink-0 mt-[2px] text-[18px] leading-none"
|
|
106
|
+
:class="item.icon"
|
|
107
|
+
/>
|
|
108
|
+
<span class="min-w-0">
|
|
109
|
+
<span class="block text-[13px] leading-snug">{{ item.title }}</span>
|
|
110
|
+
<span
|
|
111
|
+
v-if="item.hint"
|
|
112
|
+
class="block text-[10px] text-[#ffffff66]"
|
|
113
|
+
>{{ item.hint }}</span>
|
|
114
|
+
</span>
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
</ul>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
import BlueInput from './BlueInput.vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asks for a name, and optionally a note to go with it: icon and title over a subtitle, the
|
|
8
|
+
* fields in their own section, then a footer whose middle explains what the buttons will do.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
modelValue: boolean
|
|
12
|
+
title: string
|
|
13
|
+
/** Field label for the name. */
|
|
14
|
+
label: string
|
|
15
|
+
icon?: string
|
|
16
|
+
/** Explains what the name will be attached to. */
|
|
17
|
+
subtitle?: string
|
|
18
|
+
initial?: string
|
|
19
|
+
/** When given, a second optional field is shown and its value comes back with the name. */
|
|
20
|
+
notesLabel?: string
|
|
21
|
+
notesInitial?: string
|
|
22
|
+
confirmLabel?: string
|
|
23
|
+
/** Footer text, for anything worth saying about the outcome rather than the input. */
|
|
24
|
+
hint?: string
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
(event: 'update:modelValue', value: boolean): void
|
|
29
|
+
(event: 'confirm', name: string, notes: string): void
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const name = ref(props.initial ?? '')
|
|
33
|
+
const notes = ref(props.notesInitial ?? '')
|
|
34
|
+
const dialogRef = ref<HTMLDialogElement | null>(null)
|
|
35
|
+
|
|
36
|
+
// Reset on open rather than on close, so the fields are right before the dialog animates in.
|
|
37
|
+
watch(
|
|
38
|
+
() => props.modelValue,
|
|
39
|
+
(open) => {
|
|
40
|
+
if (open) {
|
|
41
|
+
name.value = props.initial ?? ''
|
|
42
|
+
notes.value = props.notesInitial ?? ''
|
|
43
|
+
}
|
|
44
|
+
const el = dialogRef.value
|
|
45
|
+
if (!el) return
|
|
46
|
+
if (open && !el.open) el.showModal()
|
|
47
|
+
if (!open && el.open) el.close()
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
onMounted(() => {
|
|
52
|
+
if (props.modelValue) dialogRef.value?.showModal()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function close(): void {
|
|
56
|
+
// Every way out of the dialog closes the element, and its close event is what reports back,
|
|
57
|
+
// so Escape and the backdrop need no separate handling.
|
|
58
|
+
dialogRef.value?.close()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function confirm(): void {
|
|
62
|
+
const trimmed = name.value.trim()
|
|
63
|
+
if (!trimmed) return
|
|
64
|
+
close()
|
|
65
|
+
emit('confirm', trimmed, notes.value.trim())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A click landing on the dialog itself is a click on the backdrop, since the panel covers the
|
|
69
|
+
// whole of the element's own box.
|
|
70
|
+
function onBackdropClick(event: MouseEvent): void {
|
|
71
|
+
if (event.target === dialogRef.value) close()
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<dialog
|
|
77
|
+
ref="dialogRef"
|
|
78
|
+
class="bluevue-dialog"
|
|
79
|
+
@close="emit('update:modelValue', false)"
|
|
80
|
+
@click="onBackdropClick"
|
|
81
|
+
>
|
|
82
|
+
<div class="bluevue-panel w-[624px] max-w-full rounded-lg text-white">
|
|
83
|
+
<div class="flex items-start gap-3 px-5 pt-4 pb-3">
|
|
84
|
+
<span
|
|
85
|
+
class="mdi mt-[2px] shrink-0 text-[22px] leading-none text-[#42A5F5]"
|
|
86
|
+
:class="icon || 'mdi-content-save-outline'"
|
|
87
|
+
/>
|
|
88
|
+
<div class="flex-1 min-w-0">
|
|
89
|
+
<div class="text-base leading-tight truncate">
|
|
90
|
+
{{ title }}
|
|
91
|
+
</div>
|
|
92
|
+
<div
|
|
93
|
+
v-if="subtitle"
|
|
94
|
+
class="text-xs text-[#ffffff88] mt-1 leading-relaxed"
|
|
95
|
+
>
|
|
96
|
+
{{ subtitle }}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
class="cursor-pointer text-[#ffffffaa] transition-colors hover:text-white"
|
|
102
|
+
title="Close"
|
|
103
|
+
@click="close"
|
|
104
|
+
>
|
|
105
|
+
<span class="mdi mdi-close text-[20px] leading-none" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="flex flex-col gap-3 border-t border-[#ffffff0d] px-5 py-4">
|
|
110
|
+
<BlueInput
|
|
111
|
+
v-model="name"
|
|
112
|
+
name="bluevue-prompt-name"
|
|
113
|
+
:label="label"
|
|
114
|
+
theme="dark"
|
|
115
|
+
width="380px"
|
|
116
|
+
autofocus
|
|
117
|
+
@keydown.enter="confirm"
|
|
118
|
+
/>
|
|
119
|
+
<BlueInput
|
|
120
|
+
v-if="notesLabel"
|
|
121
|
+
v-model="notes"
|
|
122
|
+
name="bluevue-prompt-notes"
|
|
123
|
+
:label="notesLabel"
|
|
124
|
+
theme="dark"
|
|
125
|
+
width="380px"
|
|
126
|
+
@keydown.enter="confirm"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="flex items-center justify-between gap-3 border-t border-[#ffffff0d] px-5 py-3">
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="h-7 shrink-0 cursor-pointer rounded-[4px] bg-[#ffffff11] px-3 text-[13px] font-medium uppercase tracking-wide transition-colors hover:bg-[#ffffff22]"
|
|
134
|
+
@click="close"
|
|
135
|
+
>
|
|
136
|
+
Cancel
|
|
137
|
+
</button>
|
|
138
|
+
<span class="flex-1 text-center text-xs text-[#ffffff88]">{{ hint }}</span>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
:disabled="!name.trim()"
|
|
142
|
+
class="h-7 shrink-0 rounded-[4px] bg-[var(--bluevue-primary)] px-3 text-[13px] font-medium uppercase tracking-wide transition-colors hover:brightness-125 disabled:cursor-not-allowed disabled:bg-[#9e9e9e80] disabled:text-[#afafaf] disabled:opacity-30 disabled:hover:brightness-100"
|
|
143
|
+
:class="name.trim() ? 'cursor-pointer' : ''"
|
|
144
|
+
@click="confirm"
|
|
145
|
+
>
|
|
146
|
+
{{ confirmLabel || 'Save' }}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</dialog>
|
|
151
|
+
</template>
|