@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.
Files changed (91) hide show
  1. package/README.md +97 -18
  2. package/bin/create-blueos-extension.mjs +72 -0
  3. package/dist/bluevue.js +2464 -778
  4. package/dist/components/BlueApp.vue.d.ts +50 -0
  5. package/dist/components/BlueBanner.vue.d.ts +49 -0
  6. package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
  7. package/dist/components/BlueButton.vue.d.ts +59 -0
  8. package/dist/components/BlueCard.vue.d.ts +45 -0
  9. package/dist/components/BlueCheckbox.vue.d.ts +34 -0
  10. package/dist/components/BlueChip.vue.d.ts +47 -0
  11. package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
  12. package/dist/components/BlueDialog.vue.d.ts +60 -0
  13. package/dist/components/BlueFileDrop.vue.d.ts +32 -0
  14. package/dist/components/BlueIcon.vue.d.ts +26 -0
  15. package/dist/components/BlueInput.vue.d.ts +1 -0
  16. package/dist/components/BlueProgressBar.vue.d.ts +28 -0
  17. package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
  18. package/dist/components/BlueSection.vue.d.ts +36 -0
  19. package/dist/components/BlueSnackbar.vue.d.ts +19 -0
  20. package/dist/components/BlueSpinner.vue.d.ts +25 -0
  21. package/dist/components/BlueStat.vue.d.ts +21 -0
  22. package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
  23. package/dist/components/BlueTable.vue.d.ts +62 -0
  24. package/dist/components/BlueTabs.vue.d.ts +37 -0
  25. package/dist/components/BlueTextarea.vue.d.ts +40 -0
  26. package/dist/components/BlueTooltip.vue.d.ts +44 -0
  27. package/dist/components/BlueWindRose.vue.d.ts +37 -0
  28. package/dist/composables/useBlueLoading.d.ts +22 -0
  29. package/dist/composables/useBlueOs.d.ts +39 -0
  30. package/dist/composables/useBlueSnackbar.d.ts +30 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/services/blueos.d.ts +37 -0
  33. package/dist/style.css +1 -1
  34. package/dist/types/banner.d.ts +16 -0
  35. package/dist/utils/files.d.ts +13 -0
  36. package/dist/utils/theme.d.ts +22 -0
  37. package/package.json +8 -2
  38. package/src/components/BlueApp.vue +84 -0
  39. package/src/components/BlueBanner.vue +196 -0
  40. package/src/components/BlueBannerGroup.vue +47 -0
  41. package/src/components/BlueButton.vue +134 -0
  42. package/src/components/BlueButtonGroup.vue +26 -24
  43. package/src/components/BlueCard.vue +62 -0
  44. package/src/components/BlueCheckbox.vue +100 -0
  45. package/src/components/BlueChip.vue +77 -0
  46. package/src/components/BlueConfirmDialog.vue +73 -0
  47. package/src/components/BlueDialog.vue +135 -0
  48. package/src/components/BlueFileDrop.vue +94 -0
  49. package/src/components/BlueIcon.vue +37 -0
  50. package/src/components/BlueInput.vue +21 -11
  51. package/src/components/BlueLoadingDialog.vue +22 -53
  52. package/src/components/BlueProgressBar.vue +46 -0
  53. package/src/components/BluePromptDialog.vue +51 -99
  54. package/src/components/BlueRadioGroup.vue +104 -0
  55. package/src/components/BlueSection.vue +33 -0
  56. package/src/components/BlueSelect.vue +12 -10
  57. package/src/components/BlueSlider.vue +1 -3
  58. package/src/components/BlueSnackbar.vue +79 -0
  59. package/src/components/BlueSpinner.vue +37 -0
  60. package/src/components/BlueStat.vue +26 -0
  61. package/src/components/BlueStepsDialog.vue +180 -0
  62. package/src/components/BlueSwitch.vue +16 -13
  63. package/src/components/BlueTable.vue +154 -0
  64. package/src/components/BlueTabs.vue +77 -0
  65. package/src/components/BlueTextarea.vue +101 -0
  66. package/src/components/BlueTooltip.vue +88 -0
  67. package/src/components/BlueWindRose.vue +267 -0
  68. package/src/composables/useBlueLoading.ts +38 -0
  69. package/src/composables/useBlueOs.ts +154 -0
  70. package/src/composables/useBlueSnackbar.ts +89 -0
  71. package/src/index.ts +40 -0
  72. package/src/services/blueos.ts +95 -0
  73. package/src/styles/bluevue.css +136 -0
  74. package/src/types/banner.ts +17 -0
  75. package/src/utils/files.ts +44 -0
  76. package/src/utils/theme.ts +37 -0
  77. package/templates/extension/.github/workflows/deploy.yml +23 -0
  78. package/templates/extension/Dockerfile +56 -0
  79. package/templates/extension/README.md +35 -0
  80. package/templates/extension/backend/main.py +45 -0
  81. package/templates/extension/backend/requirements.txt +2 -0
  82. package/templates/extension/frontend/.eslintrc.cjs +20 -0
  83. package/templates/extension/frontend/env.d.ts +7 -0
  84. package/templates/extension/frontend/index.html +12 -0
  85. package/templates/extension/frontend/package.json +30 -0
  86. package/templates/extension/frontend/src/App.vue +51 -0
  87. package/templates/extension/frontend/src/main.ts +8 -0
  88. package/templates/extension/frontend/src/styles/global.css +11 -0
  89. package/templates/extension/frontend/tsconfig.json +25 -0
  90. package/templates/extension/frontend/tsconfig.node.json +11 -0
  91. 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
- <div
17
+ <BlueTooltip
18
18
  v-if="infoTooltip"
19
- class="relative group inline-flex items-center shrink-0 mr-2"
19
+ :text="infoTooltip"
20
+ :theme="theme"
21
+ class="items-center shrink-0 mr-2"
20
22
  >
21
- <span
22
- class="mdi mdi-information-outline text-[16px] opacity-60 cursor-help"
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
- <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>
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
- <!-- 6px, of which the knob's 2px inset takes the first two: what is left is the 4px the
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-[6px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
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-[6px] text-[14px] whitespace-nowrap pointer-events-none transition-opacity duration-300"
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>