@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,117 @@
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
+ class="block truncate text-start mr-6"
12
+ :title="label"
13
+ :class="theme === 'dark' ? 'text-white' : 'text-black'"
14
+ >{{ label }}</label>
15
+ </div>
16
+ <div class="flex items-center min-w-0 shrink-[1000]">
17
+ <div
18
+ v-if="infoTooltip"
19
+ class="relative group inline-flex items-center shrink-0 mr-2"
20
+ >
21
+ <span
22
+ class="mdi mdi-information-outline text-[16px] opacity-60 cursor-help"
23
+ :class="theme === 'dark' ? 'text-white' : 'text-black'"
24
+ />
25
+ <div
26
+ 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]"
27
+ >
28
+ {{ infoTooltip }}
29
+ </div>
30
+ </div>
31
+ <!-- Both labels are laid out rather than drawn over: two equal columns take their width
32
+ from the longer of the two, so the track grows to whatever it has to say and the knob
33
+ covering either side has the same room. -->
34
+ <div
35
+ name="switch-track"
36
+ class="relative grid grid-cols-2 rounded-[8px] bluevue-elevation-1 cursor-pointer overflow-hidden"
37
+ :class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30 cursor-not-allowed' : '']"
38
+ :style="{ minWidth: width || '75px', height: height || '30px' }"
39
+ @click="toggleSwitch"
40
+ >
41
+ <div
42
+ class="absolute top-[4px] bottom-[4px] w-[calc(50%-4px)] rounded-[8px] bluevue-elevation-1 transition-all duration-300"
43
+ :style="{
44
+ left: modelValue ? 'calc(50% + 2px)' : '2px',
45
+ backgroundColor: modelValue ? color || 'var(--bluevue-primary)' : '#777777',
46
+ }"
47
+ />
48
+ <!-- 6px, of which the knob's 2px inset takes the first two: what is left is the 4px the
49
+ label keeps from the knob's edge. -->
50
+ <span
51
+ class="relative flex items-center justify-center px-[6px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
52
+ :class="labelClass(!modelValue)"
53
+ >
54
+ {{ labelOff || 'Off' }}
55
+ </span>
56
+ <span
57
+ class="relative flex items-center justify-center px-[6px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
58
+ :class="labelClass(modelValue)"
59
+ >
60
+ {{ labelOn || 'On' }}
61
+ </span>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </template>
66
+
67
+ <script setup lang="ts">
68
+ import { ref, watch } from 'vue'
69
+
70
+ const props = defineProps<{
71
+ /** Color of the switch's knob when it is on. */
72
+ color?: string
73
+ /** The current value of the switch. */
74
+ modelValue: boolean | null
75
+ /** Whether the switch is disabled. */
76
+ disabled?: boolean
77
+ /** Height of the switch container. */
78
+ height?: string
79
+ /** Label on the left side of the component. */
80
+ label?: string
81
+ /** Optional info tooltip shown via an info icon next to the control. */
82
+ infoTooltip?: string
83
+ /** Custom text for the switch when it is on. */
84
+ labelOn?: string
85
+ /** Custom text for the switch when it is off. */
86
+ labelOff?: string
87
+ /** Name of the component's container. */
88
+ name: string
89
+ /** Theme of the component. */
90
+ theme?: 'light' | 'dark'
91
+ /** Minimum width of the container. */
92
+ width?: string
93
+ }>()
94
+
95
+ const emit = defineEmits<{
96
+ (e: 'update:modelValue', value: boolean): void
97
+ }>()
98
+
99
+ const modelValue = ref(props.modelValue || false)
100
+
101
+ // The label the knob sits under is white against its fill; the other one stays as a dim
102
+ // reminder of what the far position says.
103
+ const labelClass = (active: boolean): string[] =>
104
+ active ? ['text-white'] : ['opacity-20', props.theme === 'dark' ? 'text-white' : 'text-black']
105
+
106
+ const toggleSwitch = (): void => {
107
+ if (props.disabled) return
108
+ modelValue.value = !modelValue.value
109
+ emit('update:modelValue', modelValue.value)
110
+ }
111
+
112
+ watch(
113
+ () => props.modelValue,
114
+ (v) => (modelValue.value = v ?? false),
115
+ { immediate: true }
116
+ )
117
+ </script>
@@ -0,0 +1,153 @@
1
+ import { autoUpdate, flip, offset, type Placement, shift, size, useFloating, type VirtualElement } from '@floating-ui/vue'
2
+ import { computed, type CSSProperties, nextTick, ref, type Ref } from 'vue'
3
+
4
+ interface BluePopoverOptions {
5
+ /** Where the menu hangs off its anchor (default 'bottom-end'). */
6
+ placement?: Placement
7
+ /** Give the menu at least its anchor's width, for a select whose list should line up with it. */
8
+ matchAnchorWidth?: boolean
9
+ }
10
+
11
+ interface BluePopover {
12
+ /** Goes on the element the menu hangs off, when it opens from a control rather than a pointer. */
13
+ anchorRef: Ref<HTMLElement | null>
14
+ /** Goes on the element carrying the `popover` attribute. */
15
+ popoverRef: Ref<HTMLElement | null>
16
+ /** The popover's id, for the `popovertarget` the activator is bound to. */
17
+ popoverId: string
18
+ /** Bind onto an activator `<button>` to let the browser own the open and close. */
19
+ activatorProps: { popovertarget: string }
20
+ isOpen: Ref<boolean>
21
+ /** False until the menu has been placed, so it can be held invisible for that first frame. */
22
+ isPositioned: Readonly<Ref<boolean>>
23
+ floatingStyles: Readonly<Ref<CSSProperties>>
24
+ /** Opens the menu, at a viewport position when one is given instead of an anchor element. */
25
+ show: (pointerTarget?: [number, number]) => Promise<void>
26
+ hide: () => void
27
+ /** Bind to the popover's `toggle` event, so a dismissal by the browser is reflected back. */
28
+ onToggle: (event: Event) => void
29
+ }
30
+
31
+ let popoverCount = 0
32
+
33
+ // The browser dismisses an auto popover on the pointerup whose pointerdown landed before the
34
+ // popover existed, which is every menu summoned mid-gesture: a right-click, a long press. Knowing
35
+ // a button is still held is what lets such a menu wait for the release instead of being closed by
36
+ // it. Tracked once for all popovers, and only from the first one that is created, so importing
37
+ // the composable touches no document.
38
+ let pointerIsDown = false
39
+ let pointerTracked = false
40
+
41
+ const trackPointerState = (): void => {
42
+ if (pointerTracked || typeof document === 'undefined') return
43
+ pointerTracked = true
44
+ const release = (): void => { pointerIsDown = false }
45
+ document.addEventListener('pointerdown', () => { pointerIsDown = true }, true)
46
+ document.addEventListener('pointerup', release, true)
47
+ document.addEventListener('pointercancel', release, true)
48
+ }
49
+
50
+ const afterPointerRelease = (): Promise<void> =>
51
+ new Promise((resolve) => {
52
+ const done = (): void => {
53
+ document.removeEventListener('pointerup', done)
54
+ document.removeEventListener('pointercancel', done)
55
+ // Resolved a task later, so the browser has already had its turn at dismissing on this
56
+ // release and the menu opens into the quiet after it.
57
+ setTimeout(resolve)
58
+ }
59
+ document.addEventListener('pointerup', done)
60
+ document.addEventListener('pointercancel', done)
61
+ })
62
+
63
+ /**
64
+ * A menu that opens in the top layer, so it is never clipped by the panel it belongs to and
65
+ * needs no z-index of its own. The native popover dismisses it on outside click and on Escape;
66
+ * floating-ui keeps it beside its anchor and inside the viewport.
67
+ * @param {BluePopoverOptions} options Placement, and whether to match the anchor's width.
68
+ * @returns {BluePopover} Refs to bind, open state, and imperative open and close.
69
+ */
70
+ export function useBluePopover(options: BluePopoverOptions = {}): BluePopover {
71
+ trackPointerState()
72
+
73
+ const anchorRef = ref<HTMLElement | null>(null)
74
+ const popoverRef = ref<HTMLElement | null>(null)
75
+ const isOpen = ref(false)
76
+ const popoverId = `bluevue-popover-${++popoverCount}`
77
+
78
+ // Set only when the menu is opened at a pointer position, as a right-click or long-press does.
79
+ const pointerTarget = ref<[number, number] | null>(null)
80
+
81
+ const virtualAnchor = computed<VirtualElement | null>(() => {
82
+ if (!pointerTarget.value) return null
83
+ const [x, y] = pointerTarget.value
84
+ return { getBoundingClientRect: () => new DOMRect(x, y, 0, 0) }
85
+ })
86
+
87
+ // Null while closed, so scroll and resize are only observed for a menu that is on screen.
88
+ const reference = computed(() => (isOpen.value ? virtualAnchor.value ?? anchorRef.value : null))
89
+
90
+ const { floatingStyles, isPositioned } = useFloating(reference, popoverRef, {
91
+ // Stated so isPositioned drops back to false on close, and a second open is held invisible
92
+ // until it has been placed rather than showing for a frame at wherever it last was.
93
+ open: isOpen,
94
+ placement: options.placement ?? 'bottom-end',
95
+ strategy: 'fixed',
96
+ whileElementsMounted: autoUpdate,
97
+ middleware: [
98
+ offset(4),
99
+ flip({ padding: 8 }),
100
+ shift({ padding: 8 }),
101
+ ...(options.matchAnchorWidth
102
+ ? [size({
103
+ apply: ({ rects, elements }) => {
104
+ elements.floating.style.minWidth = `${rects.reference.width}px`
105
+ },
106
+ })]
107
+ : []),
108
+ ],
109
+ })
110
+
111
+ // Bumped by every open and close, so an open that waited out a pointer release can tell it is
112
+ // still the one wanted and does not arrive after something already asked for the opposite.
113
+ let request = 0
114
+
115
+ const show = async (target?: [number, number]): Promise<void> => {
116
+ const ticket = ++request
117
+ pointerTarget.value = target ?? null
118
+ await nextTick()
119
+ if (pointerIsDown) await afterPointerRelease()
120
+ if (ticket !== request) return
121
+ const el = popoverRef.value
122
+ if (!el || el.matches(':popover-open')) return
123
+ // Opening it is what sets isOpen, through the toggle event the browser fires.
124
+ el.showPopover()
125
+ }
126
+
127
+ const hide = (): void => {
128
+ request += 1
129
+ const el = popoverRef.value
130
+ if (el?.matches(':popover-open')) el.hidePopover()
131
+ else isOpen.value = false
132
+ }
133
+
134
+ const onToggle = (event: Event): void => {
135
+ isOpen.value = (event as ToggleEvent).newState === 'open'
136
+ // A menu placed at a pointer keeps its target only while it is up, so the next open off an
137
+ // activator is not still anchored to the last right-click.
138
+ if (!isOpen.value) pointerTarget.value = null
139
+ }
140
+
141
+ return {
142
+ anchorRef,
143
+ popoverRef,
144
+ popoverId,
145
+ activatorProps: { popovertarget: popoverId },
146
+ isOpen,
147
+ isPositioned,
148
+ floatingStyles,
149
+ show,
150
+ hide,
151
+ onToggle,
152
+ }
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { default as BlueButtonGroup } from './components/BlueButtonGroup.vue'
2
+ export { default as BlueExpansiblePanel } from './components/BlueExpansiblePanel.vue'
3
+ export { default as BlueInput } from './components/BlueInput.vue'
4
+ export { default as BlueLoadingDialog } from './components/BlueLoadingDialog.vue'
5
+ export { default as BlueMenu, type BlueMenuItem } from './components/BlueMenu.vue'
6
+ export { default as BluePromptDialog } from './components/BluePromptDialog.vue'
7
+ export { default as BlueSelect } from './components/BlueSelect.vue'
8
+ export { default as BlueSlider } from './components/BlueSlider.vue'
9
+ export { default as BlueSwitch } from './components/BlueSwitch.vue'
10
+
11
+ export { useBluePopover } from './composables/useBluePopover'
@@ -0,0 +1,124 @@
1
+ /* Tokens and component classes: everything the controls need that is not a Tailwind utility.
2
+ Import this on its own when your app compiles the utilities itself (see the README), or get
3
+ it folded into dist/style.css when it does not. */
4
+
5
+ :root {
6
+ --bluevue-primary: #0B5087;
7
+ --bluevue-error: #CF6679;
8
+ --bluevue-surface: #363636;
9
+ --bluevue-accent: #6699CC;
10
+ --bluevue-panel-bg: rgba(30, 30, 30, 0.96);
11
+ --bluevue-hairline: rgba(255, 255, 255, 0.08);
12
+ /* Material's three-part shadow, at the two depths the controls use: a resting control, and
13
+ one raised above its neighbours (a selected segment, an open menu). */
14
+ --bluevue-elevation-1: 0 2px 1px -1px #0003, 0 1px 1px 0 #00000024, 0 1px 3px 0 #0000001f;
15
+ --bluevue-elevation-5: 0 3px 5px -1px #0003, 0 5px 8px 0 #00000024, 0 1px 14px 0 #0000001f;
16
+ /* The opposite of an elevation: a well cut into the surface, for somewhere a value is typed
17
+ rather than somewhere that is pressed. The light line along the bottom edge is what sells
18
+ the depth, by reading as the lit far wall of the recess. */
19
+ --bluevue-inset-1: inset 0 1px 2px #00000056, inset 0 0.5px 0.5px #0000003a, inset 0 -0.5px 0 #ffffff0b;
20
+ }
21
+
22
+ .bluevue-elevation-1 {
23
+ box-shadow: var(--bluevue-elevation-1);
24
+ }
25
+
26
+ .bluevue-elevation-5 {
27
+ box-shadow: var(--bluevue-elevation-5);
28
+ }
29
+
30
+ .bluevue-inset-1 {
31
+ box-shadow: var(--bluevue-inset-1);
32
+ }
33
+
34
+ /* The surface the dialogs are built on: a frosted near-black card with a hairline edge. */
35
+ .bluevue-panel {
36
+ background-color: var(--bluevue-panel-bg);
37
+ backdrop-filter: blur(8px);
38
+ -webkit-backdrop-filter: blur(8px);
39
+ border: 1px solid var(--bluevue-hairline);
40
+ box-shadow: 0 4px 4px rgba(0, 0, 0, 0.2), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
41
+ }
42
+
43
+ /* A modal <dialog> carries the browser's own centring, borders and padding. Strip them so the
44
+ panel inside is the whole of what shows, and dim the page behind it. */
45
+ .bluevue-dialog {
46
+ margin: auto;
47
+ border: none;
48
+ padding: 0;
49
+ background: transparent;
50
+ max-width: min(92vw, 624px);
51
+ max-height: 92vh;
52
+ overflow: visible;
53
+ }
54
+
55
+ /* showModal() moves focus into the dialog, and with nothing focusable inside it the dialog
56
+ itself takes it and the browser rings the whole panel. The ring belongs on controls, not on
57
+ the surface holding them, and the controls inside keep their own. */
58
+ .bluevue-dialog:focus,
59
+ .bluevue-dialog:focus-visible,
60
+ .bluevue-popover:focus,
61
+ .bluevue-popover:focus-visible {
62
+ outline: none;
63
+ }
64
+
65
+ .bluevue-dialog::backdrop {
66
+ background: rgba(0, 0, 0, 0.88);
67
+ backdrop-filter: blur(8px);
68
+ -webkit-backdrop-filter: blur(8px);
69
+ }
70
+
71
+ /* A popover is promoted to the top layer, so it needs no z-index and is not clipped by the
72
+ panel it opens from. Only the browser's default centring and chrome have to go, since
73
+ floating-ui writes the real position. */
74
+ .bluevue-popover {
75
+ position: fixed;
76
+ margin: 0;
77
+ border: none;
78
+ padding: 0;
79
+ background: transparent;
80
+ overflow: visible;
81
+ }
82
+
83
+ /* BlueExpansiblePanel's open and close. */
84
+ .bluevue-expand-enter-active,
85
+ .bluevue-expand-leave-active {
86
+ transition: max-height 0.3s ease;
87
+ overflow: hidden;
88
+ }
89
+
90
+ .bluevue-expand-enter-from,
91
+ .bluevue-expand-leave-to {
92
+ max-height: 0;
93
+ }
94
+
95
+ .bluevue-expand-enter-to,
96
+ .bluevue-expand-leave-from {
97
+ max-height: 500px;
98
+ }
99
+
100
+ /* BlueLoadingDialog's mark is a propeller, so it turns like one: a creep of 80 degrees over two
101
+ seconds, then the balance of four turns over the next two, carrying it round to where it
102
+ started. */
103
+ .bluevue-loading__icon {
104
+ animation: bluevue-prop 4s infinite;
105
+ }
106
+
107
+ @keyframes bluevue-prop {
108
+ /* Each segment carries its own easing, which is what gives the blade its weight: it takes a
109
+ moment to get moving, settles at the end of the creep, then winds up into the spins and
110
+ bleeds the speed off again rather than stopping dead. The cycle ends on a whole turn, so
111
+ the loop restarts from the orientation it began at and the seam is invisible. */
112
+ 0% {
113
+ transform: rotate(0deg);
114
+ animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
115
+ }
116
+ /* 80deg at the halfway mark, leaving the other two seconds for the 1360deg that follow. */
117
+ 50% {
118
+ transform: rotate(80deg);
119
+ animation-timing-function: cubic-bezier(0.35, 0, 0.45, 1);
120
+ }
121
+ 100% {
122
+ transform: rotate(1440deg);
123
+ }
124
+ }
@@ -0,0 +1,9 @@
1
+ /* Source for the ready-made dist/style.css, for apps that do not run Tailwind themselves.
2
+ Theme and utilities only: Preflight is deliberately left out, because a component library
3
+ has no business resetting the base styles of the app that imports it. */
4
+ @import "tailwindcss/theme.css" layer(theme);
5
+ @import "tailwindcss/utilities.css" layer(utilities);
6
+
7
+ @source "../components";
8
+
9
+ @import "./bluevue.css";