@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.
@@ -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>