@bluerobotics/bluevue 0.1.0 → 0.2.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/README.md +97 -18
- package/bin/create-blueos-extension.mjs +72 -0
- package/dist/bluevue.js +2464 -778
- package/dist/components/BlueApp.vue.d.ts +50 -0
- package/dist/components/BlueBanner.vue.d.ts +49 -0
- package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
- package/dist/components/BlueButton.vue.d.ts +59 -0
- package/dist/components/BlueCard.vue.d.ts +45 -0
- package/dist/components/BlueCheckbox.vue.d.ts +34 -0
- package/dist/components/BlueChip.vue.d.ts +47 -0
- package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
- package/dist/components/BlueDialog.vue.d.ts +60 -0
- package/dist/components/BlueFileDrop.vue.d.ts +32 -0
- package/dist/components/BlueIcon.vue.d.ts +26 -0
- package/dist/components/BlueInput.vue.d.ts +1 -0
- package/dist/components/BlueProgressBar.vue.d.ts +28 -0
- package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
- package/dist/components/BlueSection.vue.d.ts +36 -0
- package/dist/components/BlueSnackbar.vue.d.ts +19 -0
- package/dist/components/BlueSpinner.vue.d.ts +25 -0
- package/dist/components/BlueStat.vue.d.ts +21 -0
- package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
- package/dist/components/BlueTable.vue.d.ts +62 -0
- package/dist/components/BlueTabs.vue.d.ts +37 -0
- package/dist/components/BlueTextarea.vue.d.ts +40 -0
- package/dist/components/BlueTooltip.vue.d.ts +44 -0
- package/dist/components/BlueWindRose.vue.d.ts +37 -0
- package/dist/composables/useBlueLoading.d.ts +22 -0
- package/dist/composables/useBlueOs.d.ts +39 -0
- package/dist/composables/useBlueSnackbar.d.ts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/services/blueos.d.ts +37 -0
- package/dist/style.css +1 -1
- package/dist/types/banner.d.ts +16 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/theme.d.ts +22 -0
- package/package.json +8 -2
- package/src/components/BlueApp.vue +84 -0
- package/src/components/BlueBanner.vue +196 -0
- package/src/components/BlueBannerGroup.vue +47 -0
- package/src/components/BlueButton.vue +134 -0
- package/src/components/BlueButtonGroup.vue +26 -24
- package/src/components/BlueCard.vue +62 -0
- package/src/components/BlueCheckbox.vue +100 -0
- package/src/components/BlueChip.vue +77 -0
- package/src/components/BlueConfirmDialog.vue +73 -0
- package/src/components/BlueDialog.vue +135 -0
- package/src/components/BlueFileDrop.vue +94 -0
- package/src/components/BlueIcon.vue +37 -0
- package/src/components/BlueInput.vue +21 -11
- package/src/components/BlueLoadingDialog.vue +22 -53
- package/src/components/BlueProgressBar.vue +46 -0
- package/src/components/BluePromptDialog.vue +51 -99
- package/src/components/BlueRadioGroup.vue +104 -0
- package/src/components/BlueSection.vue +33 -0
- package/src/components/BlueSelect.vue +12 -10
- package/src/components/BlueSlider.vue +1 -3
- package/src/components/BlueSnackbar.vue +79 -0
- package/src/components/BlueSpinner.vue +37 -0
- package/src/components/BlueStat.vue +26 -0
- package/src/components/BlueStepsDialog.vue +180 -0
- package/src/components/BlueSwitch.vue +16 -13
- package/src/components/BlueTable.vue +154 -0
- package/src/components/BlueTabs.vue +77 -0
- package/src/components/BlueTextarea.vue +101 -0
- package/src/components/BlueTooltip.vue +88 -0
- package/src/components/BlueWindRose.vue +267 -0
- package/src/composables/useBlueLoading.ts +38 -0
- package/src/composables/useBlueOs.ts +154 -0
- package/src/composables/useBlueSnackbar.ts +89 -0
- package/src/index.ts +40 -0
- package/src/services/blueos.ts +95 -0
- package/src/styles/bluevue.css +136 -0
- package/src/types/banner.ts +17 -0
- package/src/utils/files.ts +44 -0
- package/src/utils/theme.ts +37 -0
- package/templates/extension/.github/workflows/deploy.yml +23 -0
- package/templates/extension/Dockerfile +56 -0
- package/templates/extension/README.md +35 -0
- package/templates/extension/backend/main.py +45 -0
- package/templates/extension/backend/requirements.txt +2 -0
- package/templates/extension/frontend/.eslintrc.cjs +20 -0
- package/templates/extension/frontend/env.d.ts +7 -0
- package/templates/extension/frontend/index.html +12 -0
- package/templates/extension/frontend/package.json +30 -0
- package/templates/extension/frontend/src/App.vue +51 -0
- package/templates/extension/frontend/src/main.ts +8 -0
- package/templates/extension/frontend/src/styles/global.css +11 -0
- package/templates/extension/frontend/tsconfig.json +25 -0
- package/templates/extension/frontend/tsconfig.node.json +11 -0
- package/templates/extension/frontend/vite.config.ts +24 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A turning ring, for a wait with no measure to it. A wait that does have one belongs in a
|
|
6
|
+
* BlueProgressBar, and one that blocks the whole page in a BlueLoadingDialog.
|
|
7
|
+
*/
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
/** Diameter, in pixels when given as a number (default 20). */
|
|
10
|
+
size?: number | string
|
|
11
|
+
/** The colour of the arc that turns (default the accent token). */
|
|
12
|
+
color?: string
|
|
13
|
+
/** Ring thickness in pixels (default 2). */
|
|
14
|
+
width?: number
|
|
15
|
+
/** What is being waited for, read out by assistive technology. */
|
|
16
|
+
label?: string
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const box = computed(() => {
|
|
20
|
+
const size = props.size ?? 20
|
|
21
|
+
return typeof size === 'number' ? `${size}px` : size
|
|
22
|
+
})
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<span
|
|
27
|
+
class="bluevue-spinner"
|
|
28
|
+
role="status"
|
|
29
|
+
:aria-label="label"
|
|
30
|
+
:style="{
|
|
31
|
+
width: box,
|
|
32
|
+
height: box,
|
|
33
|
+
borderWidth: `${width ?? 2}px`,
|
|
34
|
+
borderTopColor: color,
|
|
35
|
+
}"
|
|
36
|
+
/>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One read-only fact about the vehicle: a quiet label over its value. Meant to sit in a row of
|
|
4
|
+
* them, each taking an equal share of the width and wrapping to the next line when there is none.
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{
|
|
7
|
+
label: string
|
|
8
|
+
value?: string | number | null
|
|
9
|
+
/** What to show when there is no value yet (default an em dash). */
|
|
10
|
+
placeholder?: string
|
|
11
|
+
}>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="bluevue-elevation-1 min-w-[120px] flex-1 rounded-[6px] bg-[#00000022] px-3 py-2">
|
|
16
|
+
<div class="truncate text-[11px] uppercase tracking-wide text-[#ffffff66]">
|
|
17
|
+
{{ label }}
|
|
18
|
+
</div>
|
|
19
|
+
<div
|
|
20
|
+
class="truncate text-sm text-white"
|
|
21
|
+
:title="value === null || value === undefined || value === '' ? undefined : String(value)"
|
|
22
|
+
>
|
|
23
|
+
{{ value === null || value === undefined || value === '' ? placeholder ?? '—' : value }}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref, useSlots, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
import BlueDialog from './BlueDialog.vue'
|
|
5
|
+
import BlueIcon from './BlueIcon.vue'
|
|
6
|
+
import BlueProgressBar from './BlueProgressBar.vue'
|
|
7
|
+
|
|
8
|
+
/** One stage of the work, in the order it runs. */
|
|
9
|
+
export interface BlueStep {
|
|
10
|
+
key: string
|
|
11
|
+
title: string
|
|
12
|
+
state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
|
13
|
+
/** What the step is doing, or why it failed. */
|
|
14
|
+
detail?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reports on work with stages to it: installing firmware, writing parameters, restarting. The
|
|
19
|
+
* dialog holds the page while the work is running and lets go the moment it is not, so a run that
|
|
20
|
+
* ends in a failure can be read, retried or skipped from the footer.
|
|
21
|
+
*/
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
modelValue: boolean
|
|
24
|
+
title: string
|
|
25
|
+
/** What the current stage is doing, under the title. */
|
|
26
|
+
subtitle?: string
|
|
27
|
+
/** Running holds the page; the other two release it. */
|
|
28
|
+
state: 'running' | 'failed' | 'done'
|
|
29
|
+
steps: BlueStep[]
|
|
30
|
+
/** The line above the bar, naming what is being worked through. */
|
|
31
|
+
progressLabel?: string
|
|
32
|
+
/** Percentage of the current stage, when the stage can count itself. */
|
|
33
|
+
progress?: number
|
|
34
|
+
/** For a stage that can only say it is waiting, such as a restart. */
|
|
35
|
+
indeterminate?: boolean
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
(event: 'update:modelValue', value: boolean): void
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const slots = useSlots()
|
|
43
|
+
|
|
44
|
+
// The step icons and the connectors between them share this height, which is what puts the line
|
|
45
|
+
// on the icons' centre line.
|
|
46
|
+
const STEP_ICON_SIZE = 22
|
|
47
|
+
|
|
48
|
+
const STEP_STYLE = {
|
|
49
|
+
pending: { icon: 'mdi-circle-outline', color: '#ffffff44' },
|
|
50
|
+
running: { icon: 'mdi-progress-clock', color: '#42A5F5' },
|
|
51
|
+
done: { icon: 'mdi-check-circle', color: '#66BB6A' },
|
|
52
|
+
failed: { icon: 'mdi-alert-circle', color: '#EF5350' },
|
|
53
|
+
skipped: { icon: 'mdi-minus-circle-outline', color: '#ffffff44' },
|
|
54
|
+
} as const
|
|
55
|
+
|
|
56
|
+
const HEAD = {
|
|
57
|
+
running: { icon: 'mdi-cog-sync', color: '#42A5F5' },
|
|
58
|
+
failed: { icon: 'mdi-alert-circle', color: '#EF5350' },
|
|
59
|
+
done: { icon: 'mdi-check-circle', color: '#66BB6A' },
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
const head = computed(() => HEAD[props.state])
|
|
63
|
+
|
|
64
|
+
const hasProgress = computed(() => props.progress !== undefined || props.indeterminate || Boolean(slots.log))
|
|
65
|
+
|
|
66
|
+
const logRef = ref<HTMLElement | null>(null)
|
|
67
|
+
let logObserver: MutationObserver | null = null
|
|
68
|
+
|
|
69
|
+
// Follows the tail as records arrive, but only while the reader is already there: someone who has
|
|
70
|
+
// scrolled up to read an earlier line is reading it, and yanking them back down is not help.
|
|
71
|
+
const followLog = (): void => {
|
|
72
|
+
const el = logRef.value
|
|
73
|
+
if (!el) return
|
|
74
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) el.scrollTop = el.scrollHeight
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
watch(logRef, (el) => {
|
|
78
|
+
logObserver?.disconnect()
|
|
79
|
+
logObserver = null
|
|
80
|
+
if (!el) return
|
|
81
|
+
logObserver = new MutationObserver(followLog)
|
|
82
|
+
logObserver.observe(el, { childList: true, subtree: true, characterData: true })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
onBeforeUnmount(() => logObserver?.disconnect())
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<BlueDialog
|
|
90
|
+
:model-value="modelValue"
|
|
91
|
+
:persistent="state === 'running'"
|
|
92
|
+
width="620px"
|
|
93
|
+
body-class="px-5 pb-4 pt-3"
|
|
94
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
95
|
+
>
|
|
96
|
+
<template #header>
|
|
97
|
+
<BlueIcon
|
|
98
|
+
:name="head.icon"
|
|
99
|
+
:size="22"
|
|
100
|
+
:color="head.color"
|
|
101
|
+
class="mt-[2px]"
|
|
102
|
+
/>
|
|
103
|
+
<div class="min-w-0 flex-1">
|
|
104
|
+
<div class="truncate text-base leading-tight">
|
|
105
|
+
{{ title }}
|
|
106
|
+
</div>
|
|
107
|
+
<div
|
|
108
|
+
v-if="subtitle"
|
|
109
|
+
class="mt-1 truncate text-xs text-[#ffffff88]"
|
|
110
|
+
>
|
|
111
|
+
{{ subtitle }}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<div class="flex items-start">
|
|
117
|
+
<template
|
|
118
|
+
v-for="(step, index) in steps"
|
|
119
|
+
:key="step.key"
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
v-if="index"
|
|
123
|
+
class="flex flex-1 items-center"
|
|
124
|
+
:style="{ height: `${STEP_ICON_SIZE}px` }"
|
|
125
|
+
>
|
|
126
|
+
<div class="h-px w-full bg-[#ffffff1a]" />
|
|
127
|
+
</div>
|
|
128
|
+
<!-- Wide enough for the longest step title to stay on one line before the ellipsis. -->
|
|
129
|
+
<div class="flex w-[84px] shrink-0 flex-col items-center gap-1">
|
|
130
|
+
<BlueIcon
|
|
131
|
+
:name="STEP_STYLE[step.state].icon"
|
|
132
|
+
:size="STEP_ICON_SIZE"
|
|
133
|
+
:color="STEP_STYLE[step.state].color"
|
|
134
|
+
/>
|
|
135
|
+
<span
|
|
136
|
+
class="w-full truncate text-center text-[10px] leading-tight"
|
|
137
|
+
:title="step.detail || step.title"
|
|
138
|
+
:class="step.state === 'running' ? 'text-white' : 'text-[#ffffff77]'"
|
|
139
|
+
>
|
|
140
|
+
{{ step.title }}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
v-if="hasProgress"
|
|
148
|
+
class="mt-4 border-t border-[#ffffff0d] pt-4"
|
|
149
|
+
>
|
|
150
|
+
<div class="mb-2 flex items-baseline justify-between">
|
|
151
|
+
<span class="truncate text-xs text-[#ffffffcc]">{{ progressLabel }}</span>
|
|
152
|
+
<div class="flex shrink-0 gap-2 pl-3">
|
|
153
|
+
<slot name="badges" />
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<BlueProgressBar
|
|
158
|
+
:model-value="progress ?? 100"
|
|
159
|
+
:indeterminate="indeterminate && state === 'running'"
|
|
160
|
+
:color="state === 'failed' ? 'var(--bluevue-error)' : undefined"
|
|
161
|
+
:label="progressLabel"
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
<div
|
|
165
|
+
v-if="slots.log"
|
|
166
|
+
ref="logRef"
|
|
167
|
+
class="mt-3 max-h-[220px] overflow-y-auto rounded-[6px] bg-[#00000033] px-3 py-2 font-mono text-[11px]"
|
|
168
|
+
>
|
|
169
|
+
<slot name="log" />
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<template
|
|
174
|
+
v-if="slots.footer"
|
|
175
|
+
#footer
|
|
176
|
+
>
|
|
177
|
+
<slot name="footer" />
|
|
178
|
+
</template>
|
|
179
|
+
</BlueDialog>
|
|
180
|
+
</template>
|
|
@@ -14,20 +14,20 @@
|
|
|
14
14
|
>{{ label }}</label>
|
|
15
15
|
</div>
|
|
16
16
|
<div class="flex items-center min-w-0 shrink-[1000]">
|
|
17
|
-
<
|
|
17
|
+
<BlueTooltip
|
|
18
18
|
v-if="infoTooltip"
|
|
19
|
-
|
|
19
|
+
:text="infoTooltip"
|
|
20
|
+
:theme="theme"
|
|
21
|
+
class="items-center shrink-0 mr-2"
|
|
20
22
|
>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
+
<BlueIcon
|
|
24
|
+
name="mdi-information-outline"
|
|
25
|
+
:size="16"
|
|
26
|
+
:label="infoTooltip"
|
|
27
|
+
class="opacity-60 cursor-help"
|
|
23
28
|
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
24
29
|
/>
|
|
25
|
-
|
|
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>
|
|
30
|
+
</BlueTooltip>
|
|
31
31
|
<!-- Both labels are laid out rather than drawn over: two equal columns take their width
|
|
32
32
|
from the longer of the two, so the track grows to whatever it has to say and the knob
|
|
33
33
|
covering either side has the same room. -->
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
backgroundColor: modelValue ? color || 'var(--bluevue-primary)' : '#777777',
|
|
46
46
|
}"
|
|
47
47
|
/>
|
|
48
|
-
<!--
|
|
48
|
+
<!-- 9px, of which the knob's 2px inset takes the first two: what is left is the 7px the
|
|
49
49
|
label keeps from the knob's edge. -->
|
|
50
50
|
<span
|
|
51
|
-
class="relative flex items-center justify-center px-[
|
|
51
|
+
class="relative flex items-center justify-center px-[9px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
|
|
52
52
|
:class="labelClass(!modelValue)"
|
|
53
53
|
>
|
|
54
54
|
{{ labelOff || 'Off' }}
|
|
55
55
|
</span>
|
|
56
56
|
<span
|
|
57
|
-
class="relative flex items-center justify-center px-[
|
|
57
|
+
class="relative flex items-center justify-center px-[9px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
|
|
58
58
|
:class="labelClass(modelValue)"
|
|
59
59
|
>
|
|
60
60
|
{{ labelOn || 'On' }}
|
|
@@ -67,6 +67,9 @@
|
|
|
67
67
|
<script setup lang="ts">
|
|
68
68
|
import { ref, watch } from 'vue'
|
|
69
69
|
|
|
70
|
+
import BlueIcon from './BlueIcon.vue'
|
|
71
|
+
import BlueTooltip from './BlueTooltip.vue'
|
|
72
|
+
|
|
70
73
|
const props = defineProps<{
|
|
71
74
|
/** Color of the switch's knob when it is on. */
|
|
72
75
|
color?: string
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import BlueIcon from './BlueIcon.vue'
|
|
5
|
+
|
|
6
|
+
/** One column of the table. */
|
|
7
|
+
export interface BlueColumn {
|
|
8
|
+
/** The field it reads from each row, and the name of its cell slot. */
|
|
9
|
+
key: string
|
|
10
|
+
title: string
|
|
11
|
+
width?: string
|
|
12
|
+
align?: 'start' | 'center' | 'end'
|
|
13
|
+
/** Lets the header be pressed to order the rows by this column. */
|
|
14
|
+
sortable?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rows of records under a header that stays put while they scroll. Sorting is its own, so a
|
|
19
|
+
* consumer hands it a list and gets an ordered table without keeping the order itself.
|
|
20
|
+
*/
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
columns: BlueColumn[]
|
|
23
|
+
rows: Record<string, unknown>[]
|
|
24
|
+
/** The field that identifies a row. Falls back to the row's place in the list. */
|
|
25
|
+
rowKey?: string
|
|
26
|
+
/** Column to order by before anyone presses a header. */
|
|
27
|
+
sortBy?: string
|
|
28
|
+
/** How tall the body may get before it scrolls under its header. */
|
|
29
|
+
maxHeight?: string
|
|
30
|
+
/** Tighter rows, for a table of many short ones. */
|
|
31
|
+
dense?: boolean
|
|
32
|
+
/** What to say instead of an empty body. */
|
|
33
|
+
emptyText?: string
|
|
34
|
+
theme?: 'light' | 'dark'
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
defineEmits<{
|
|
38
|
+
(event: 'row-click', row: Record<string, unknown>): void
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const sortKey = ref(props.sortBy ?? '')
|
|
42
|
+
const sortDesc = ref(false)
|
|
43
|
+
|
|
44
|
+
const toggleSort = (column: BlueColumn): void => {
|
|
45
|
+
if (!column.sortable) return
|
|
46
|
+
if (sortKey.value === column.key) {
|
|
47
|
+
sortDesc.value = !sortDesc.value
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
sortKey.value = column.key
|
|
51
|
+
sortDesc.value = false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Numbers compare as numbers and everything else as text, which is what keeps 9 above 10 from
|
|
55
|
+
// happening in a column of counts. Absent values sink, since a row missing the field it is being
|
|
56
|
+
// ordered by has nothing to say about where it belongs.
|
|
57
|
+
const compare = (a: unknown, b: unknown): number => {
|
|
58
|
+
if (a == null) return b == null ? 0 : 1
|
|
59
|
+
if (b == null) return -1
|
|
60
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b
|
|
61
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sorted = computed(() => {
|
|
65
|
+
if (!sortKey.value) return props.rows
|
|
66
|
+
const direction = sortDesc.value ? -1 : 1
|
|
67
|
+
return [...props.rows].sort((a, b) => direction * compare(a[sortKey.value], b[sortKey.value]))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const alignment = (column: BlueColumn): string =>
|
|
71
|
+
column.align === 'end' ? 'text-end' : column.align === 'center' ? 'text-center' : 'text-start'
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<div
|
|
76
|
+
class="w-full overflow-auto rounded-[6px]"
|
|
77
|
+
:class="theme === 'light' ? 'bg-[#00000006]' : 'bg-[#ffffff08]'"
|
|
78
|
+
:style="{ maxHeight }"
|
|
79
|
+
>
|
|
80
|
+
<table class="w-full border-collapse text-[13px]">
|
|
81
|
+
<thead>
|
|
82
|
+
<tr>
|
|
83
|
+
<th
|
|
84
|
+
v-for="column in columns"
|
|
85
|
+
:key="column.key"
|
|
86
|
+
scope="col"
|
|
87
|
+
class="sticky top-0 z-[1] whitespace-nowrap border-b px-3 py-2 text-[11px] font-medium uppercase tracking-wide backdrop-blur-sm"
|
|
88
|
+
:class="[
|
|
89
|
+
alignment(column),
|
|
90
|
+
column.sortable ? 'cursor-pointer select-none' : '',
|
|
91
|
+
theme === 'light'
|
|
92
|
+
? 'border-[#00000014] bg-[#f5f5f5cc] text-[#00000088]'
|
|
93
|
+
: 'border-[#ffffff14] bg-[#1e1e1ecc] text-[#ffffff88]',
|
|
94
|
+
]"
|
|
95
|
+
:style="{ width: column.width }"
|
|
96
|
+
:aria-sort="sortKey === column.key ? (sortDesc ? 'descending' : 'ascending') : undefined"
|
|
97
|
+
@click="toggleSort(column)"
|
|
98
|
+
>
|
|
99
|
+
<span class="inline-flex items-center gap-1">
|
|
100
|
+
{{ column.title }}
|
|
101
|
+
<BlueIcon
|
|
102
|
+
v-if="column.sortable"
|
|
103
|
+
:name="sortKey === column.key ? (sortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up') : 'mdi-arrow-up-down'"
|
|
104
|
+
:size="12"
|
|
105
|
+
:class="sortKey === column.key ? '' : 'opacity-30'"
|
|
106
|
+
/>
|
|
107
|
+
</span>
|
|
108
|
+
</th>
|
|
109
|
+
</tr>
|
|
110
|
+
</thead>
|
|
111
|
+
|
|
112
|
+
<tbody>
|
|
113
|
+
<tr
|
|
114
|
+
v-for="(row, index) in sorted"
|
|
115
|
+
:key="String(rowKey ? row[rowKey] : index)"
|
|
116
|
+
class="transition-colors"
|
|
117
|
+
:class="theme === 'light' ? 'hover:bg-[#0000000a]' : 'hover:bg-[#ffffff0a]'"
|
|
118
|
+
@click="$emit('row-click', row)"
|
|
119
|
+
>
|
|
120
|
+
<td
|
|
121
|
+
v-for="column in columns"
|
|
122
|
+
:key="column.key"
|
|
123
|
+
class="border-b px-3"
|
|
124
|
+
:class="[
|
|
125
|
+
alignment(column),
|
|
126
|
+
dense ? 'py-1' : 'py-2',
|
|
127
|
+
theme === 'light' ? 'border-[#0000000a] text-black' : 'border-[#ffffff0a] text-white',
|
|
128
|
+
]"
|
|
129
|
+
>
|
|
130
|
+
<slot
|
|
131
|
+
:name="`cell-${column.key}`"
|
|
132
|
+
:row="row"
|
|
133
|
+
:value="row[column.key]"
|
|
134
|
+
>
|
|
135
|
+
{{ row[column.key] ?? '—' }}
|
|
136
|
+
</slot>
|
|
137
|
+
</td>
|
|
138
|
+
</tr>
|
|
139
|
+
|
|
140
|
+
<tr v-if="sorted.length === 0">
|
|
141
|
+
<td
|
|
142
|
+
:colspan="columns.length"
|
|
143
|
+
class="px-3 py-6 text-center text-[13px]"
|
|
144
|
+
:class="theme === 'light' ? 'text-[#00000066]' : 'text-[#ffffff66]'"
|
|
145
|
+
>
|
|
146
|
+
<slot name="empty">
|
|
147
|
+
{{ emptyText ?? 'Nothing here yet.' }}
|
|
148
|
+
</slot>
|
|
149
|
+
</td>
|
|
150
|
+
</tr>
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BlueChip from './BlueChip.vue'
|
|
3
|
+
import BlueIcon from './BlueIcon.vue'
|
|
4
|
+
|
|
5
|
+
/** One tab in the strip. */
|
|
6
|
+
export interface BlueTab {
|
|
7
|
+
/** What comes back through v-model when it is chosen. */
|
|
8
|
+
value: string | number
|
|
9
|
+
label: string
|
|
10
|
+
icon?: string
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
/** A count beside the label, such as how many things the tab holds. */
|
|
13
|
+
badge?: string | number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The strip that switches between the pages of a view. It reports which tab is current and
|
|
18
|
+
* nothing else: what each one shows is the consumer's, since a tab that renders its page only
|
|
19
|
+
* when it is current is what keeps the ones behind it from doing work.
|
|
20
|
+
*/
|
|
21
|
+
defineProps<{
|
|
22
|
+
modelValue: string | number
|
|
23
|
+
tabs: BlueTab[]
|
|
24
|
+
/** Fills the width it is given, each tab taking an equal share. */
|
|
25
|
+
stretch?: boolean
|
|
26
|
+
theme?: 'light' | 'dark'
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
(event: 'update:modelValue', value: string | number): void
|
|
31
|
+
}>()
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div
|
|
36
|
+
class="flex items-stretch gap-1 border-b"
|
|
37
|
+
:class="theme === 'light' ? 'border-[#00000014]' : 'border-[#ffffff14]'"
|
|
38
|
+
role="tablist"
|
|
39
|
+
>
|
|
40
|
+
<button
|
|
41
|
+
v-for="tab in tabs"
|
|
42
|
+
:key="tab.value"
|
|
43
|
+
type="button"
|
|
44
|
+
role="tab"
|
|
45
|
+
:aria-selected="modelValue === tab.value"
|
|
46
|
+
:disabled="tab.disabled"
|
|
47
|
+
class="relative flex items-center justify-center gap-2 whitespace-nowrap px-3 pb-2 pt-[6px] text-[13px] font-medium transition-colors"
|
|
48
|
+
:class="[
|
|
49
|
+
stretch ? 'flex-1' : '',
|
|
50
|
+
tab.disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
|
51
|
+
modelValue === tab.value
|
|
52
|
+
? theme === 'light' ? 'text-black' : 'text-white'
|
|
53
|
+
: theme === 'light' ? 'text-[#00000088] hover:text-black' : 'text-[#ffffff88] hover:text-white',
|
|
54
|
+
]"
|
|
55
|
+
@click="emit('update:modelValue', tab.value)"
|
|
56
|
+
>
|
|
57
|
+
<BlueIcon
|
|
58
|
+
v-if="tab.icon"
|
|
59
|
+
:name="tab.icon"
|
|
60
|
+
:size="16"
|
|
61
|
+
/>
|
|
62
|
+
<span class="min-w-0 truncate">{{ tab.label }}</span>
|
|
63
|
+
<BlueChip
|
|
64
|
+
v-if="tab.badge !== undefined"
|
|
65
|
+
size="small"
|
|
66
|
+
:label="String(tab.badge)"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<!-- The underline sits on the strip's own border rather than beside it, so the current tab
|
|
70
|
+
reads as continuous with what it opens onto. -->
|
|
71
|
+
<span
|
|
72
|
+
v-if="modelValue === tab.value"
|
|
73
|
+
class="absolute inset-x-0 -bottom-px h-[2px] rounded-full bg-[var(--bluevue-accent)]"
|
|
74
|
+
/>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
import BlueIcon from './BlueIcon.vue'
|
|
5
|
+
import BlueTooltip from './BlueTooltip.vue'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A BlueInput with room for several lines: the same well cut into the panel, for a note, a
|
|
9
|
+
* description, or a block of parameters pasted in.
|
|
10
|
+
*/
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
modelValue: string | null
|
|
13
|
+
/** Label above the field, since a multi-line field beside its label leaves the label floating. */
|
|
14
|
+
label?: string
|
|
15
|
+
/** Name and id, tying the label to the field. */
|
|
16
|
+
name?: string
|
|
17
|
+
placeholder?: string
|
|
18
|
+
/** Lines of room it starts with (default 4). */
|
|
19
|
+
rows?: number
|
|
20
|
+
/** Grows with its content instead of scrolling, up to maxRows. */
|
|
21
|
+
autoGrow?: boolean
|
|
22
|
+
/** How far autoGrow goes before the field starts scrolling (default 12). */
|
|
23
|
+
maxRows?: number
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
/** Validation messages, which also outline the field. */
|
|
26
|
+
errorMessages?: string[]
|
|
27
|
+
/** A hint behind an info icon beside the label. */
|
|
28
|
+
infoTooltip?: string
|
|
29
|
+
width?: string
|
|
30
|
+
theme?: 'light' | 'dark'
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
(event: 'update:modelValue', value: string): void
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
const hasError = computed(() => (props.errorMessages?.length ?? 0) > 0)
|
|
38
|
+
|
|
39
|
+
// A grown field is as tall as its content, which is the line count it would need, capped so a
|
|
40
|
+
// pasted log does not push the rest of the panel off the page.
|
|
41
|
+
const rows = computed(() => {
|
|
42
|
+
if (!props.autoGrow) return props.rows ?? 4
|
|
43
|
+
const lines = (props.modelValue ?? '').split('\n').length
|
|
44
|
+
return Math.min(Math.max(lines, props.rows ?? 4), props.maxRows ?? 12)
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div
|
|
50
|
+
class="flex w-full min-w-0 flex-col"
|
|
51
|
+
:style="{ width }"
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
v-if="label"
|
|
55
|
+
class="mb-1 flex items-center gap-2"
|
|
56
|
+
>
|
|
57
|
+
<label
|
|
58
|
+
:for="name"
|
|
59
|
+
class="min-w-0 truncate text-[13px]"
|
|
60
|
+
:class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
|
|
61
|
+
>{{ label }}</label>
|
|
62
|
+
<BlueTooltip
|
|
63
|
+
v-if="infoTooltip"
|
|
64
|
+
:text="infoTooltip"
|
|
65
|
+
:theme="theme"
|
|
66
|
+
class="items-center"
|
|
67
|
+
>
|
|
68
|
+
<BlueIcon
|
|
69
|
+
name="mdi-information-outline"
|
|
70
|
+
:size="16"
|
|
71
|
+
:label="infoTooltip"
|
|
72
|
+
class="opacity-60 cursor-help"
|
|
73
|
+
/>
|
|
74
|
+
</BlueTooltip>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<textarea
|
|
78
|
+
:id="name"
|
|
79
|
+
:name="name"
|
|
80
|
+
:rows="rows"
|
|
81
|
+
:value="modelValue ?? ''"
|
|
82
|
+
:placeholder="placeholder"
|
|
83
|
+
:disabled="disabled"
|
|
84
|
+
class="bluevue-inset-1 w-full resize-y rounded-[6px] px-3 py-2 text-sm outline-none transition-colors focus:outline focus:outline-2 focus:outline-offset-0 focus:outline-[var(--bluevue-accent)]"
|
|
85
|
+
:class="[
|
|
86
|
+
theme === 'dark' ? 'bg-[#ffffff11] text-white placeholder:text-[#ffffff55]' : 'bg-[#00000008] text-black placeholder:text-[#00000055]',
|
|
87
|
+
disabled ? 'pointer-events-none opacity-30' : '',
|
|
88
|
+
hasError ? 'outline outline-2 outline-[var(--bluevue-error)]' : '',
|
|
89
|
+
]"
|
|
90
|
+
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
<span
|
|
94
|
+
v-for="message in errorMessages ?? []"
|
|
95
|
+
:key="message"
|
|
96
|
+
class="mt-1 text-[11px] text-[var(--bluevue-error)]"
|
|
97
|
+
>
|
|
98
|
+
{{ message }}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|