@bluerobotics/bluevue 0.2.0 → 0.2.2

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 (34) hide show
  1. package/dist/bluevue.js +1907 -1624
  2. package/dist/components/BlueButton.vue.d.ts +5 -0
  3. package/dist/components/BlueButtonGroup.vue.d.ts +6 -0
  4. package/dist/components/BlueConfirmDialog.vue.d.ts +98 -21
  5. package/dist/components/BlueDialog.vue.d.ts +51 -4
  6. package/dist/components/BlueExpansiblePanel.vue.d.ts +11 -0
  7. package/dist/components/BlueHeader.vue.d.ts +41 -0
  8. package/dist/components/BlueHeaderMenu.vue.d.ts +25 -0
  9. package/dist/components/BlueHeaderSelector.vue.d.ts +42 -0
  10. package/dist/components/BlueSlider.vue.d.ts +5 -1
  11. package/dist/components/BlueSwitch.vue.d.ts +0 -2
  12. package/dist/components/BlueWindRose.vue.d.ts +2 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/style.css +1 -1
  15. package/dist/utils/id.d.ts +10 -0
  16. package/package.json +1 -1
  17. package/src/components/BlueButton.vue +22 -1
  18. package/src/components/BlueButtonGroup.vue +19 -10
  19. package/src/components/BlueCheckbox.vue +2 -2
  20. package/src/components/BlueDialog.vue +46 -7
  21. package/src/components/BlueExpansiblePanel.vue +63 -9
  22. package/src/components/BlueHeader.vue +42 -0
  23. package/src/components/BlueHeaderMenu.vue +53 -0
  24. package/src/components/BlueHeaderSelector.vue +153 -0
  25. package/src/components/BlueInput.vue +3 -3
  26. package/src/components/BlueLoadingDialog.vue +1 -1
  27. package/src/components/BlueSelect.vue +2 -2
  28. package/src/components/BlueSlider.vue +74 -16
  29. package/src/components/BlueSwitch.vue +56 -35
  30. package/src/components/BlueTextarea.vue +2 -1
  31. package/src/components/BlueWindRose.vue +21 -3
  32. package/src/index.ts +6 -0
  33. package/src/styles/bluevue.css +39 -16
  34. package/src/utils/id.ts +15 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A document-unique id, for wiring a label to the control it names.
3
+ *
4
+ * Stands in for Vue's `useId`, which arrived in 3.5 and so is out of reach of the 3.4 baseline
5
+ * this package supports.
6
+ *
7
+ * @param prefix - Short name of the control asking for it, so ids stay readable in the DOM.
8
+ * @returns The generated id.
9
+ */
10
+ export declare function nextElementId(prefix: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluerobotics/bluevue",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Blue Robotics Vue components for common UI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,11 @@ const props = defineProps<{
28
28
  disabled?: boolean
29
29
  /** Takes the whole width it is given, for a button ending a narrow panel. */
30
30
  block?: boolean
31
+ /**
32
+ * Shadow depth, 1 (a hair off the surface) to 5. Chips default to 1; text and icon buttons have
33
+ * no chip to lift, so they default to none. A disabled button is never raised.
34
+ */
35
+ elevation?: 1 | 2 | 3 | 4 | 5
31
36
  /** Hover text, and the name assistive technology reads on an icon button. */
32
37
  tooltip?: string
33
38
  theme?: 'light' | 'dark'
@@ -57,7 +62,7 @@ const look = computed(() => {
57
62
  }
58
63
  switch (variant.value) {
59
64
  case 'filled':
60
- return 'bluevue-elevation-1 cursor-pointer text-white hover:brightness-125'
65
+ return 'cursor-pointer text-white hover:brightness-125'
61
66
  case 'text':
62
67
  return isDark.value
63
68
  ? 'cursor-pointer text-[#ffffffcc] hover:bg-[#ffffff11] hover:text-white'
@@ -75,6 +80,21 @@ const look = computed(() => {
75
80
 
76
81
  const fill = computed(() => (variant.value === 'filled' && !props.disabled ? props.color || 'var(--bluevue-primary)' : undefined))
77
82
 
83
+ const ELEVATION = {
84
+ 1: 'bluevue-elevation-1',
85
+ 2: 'bluevue-elevation-2',
86
+ 3: 'bluevue-elevation-3',
87
+ 4: 'bluevue-elevation-4',
88
+ 5: 'bluevue-elevation-5',
89
+ } as const
90
+
91
+ const elevationClass = computed(() => {
92
+ if (props.disabled) return ''
93
+ const hasChip = variant.value === 'filled' || variant.value === 'tonal'
94
+ const level = props.elevation ?? (hasChip ? 1 : 0)
95
+ return level ? ELEVATION[level] : ''
96
+ })
97
+
78
98
  // The glyph of a text or icon button follows the colour it was given; a filled one draws its
79
99
  // contents in white over the fill, and a tonal one in the theme's own text colour.
80
100
  const glyphColor = computed(() =>
@@ -95,6 +115,7 @@ const glyphColor = computed(() =>
95
115
  variant === 'icon' ? 'rounded-[6px]' : 'rounded-[4px]',
96
116
  variant === 'icon' ? '' : PADDING[density],
97
117
  block ? 'w-full' : '',
118
+ elevationClass,
98
119
  // A button waiting on its own result stays where it is and reads as busy rather than
99
120
  // inviting a second press.
100
121
  loading ? 'pointer-events-none' : '',
@@ -14,7 +14,7 @@
14
14
  ref="labelRef"
15
15
  class="block truncate text-start mr-6"
16
16
  :title="label"
17
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
17
+ :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
18
18
  >{{ label }}</label>
19
19
  </div>
20
20
  <div v-else />
@@ -32,15 +32,15 @@
32
32
  name="mdi-information-outline"
33
33
  :size="16"
34
34
  :label="infoTooltip"
35
- class="opacity-60 cursor-help"
36
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
35
+ class="cursor-help"
36
+ :class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
37
37
  />
38
38
  </BlueTooltip>
39
39
  <div
40
40
  ref="trackRef"
41
- class="relative flex min-w-0 justify-end overflow-hidden rounded-[6px] bluevue-elevation-1 z-[666]"
41
+ class="relative isolate flex min-w-0 justify-end overflow-hidden rounded-[6px] bluevue-elevation-1"
42
42
  :class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30 pointer-events-none' : '']"
43
- :style="{ height: height || (density === 'compact' ? '24px' : '30px') }"
43
+ :style="{ height: height || (density === 'compact' ? '24px' : '30px'), width }"
44
44
  >
45
45
  <div
46
46
  id="border-releif"
@@ -53,7 +53,7 @@
53
53
  <button
54
54
  :disabled="disabled || btn.disabled"
55
55
  :title="btn.name"
56
- class="flex min-w-0 items-center justify-center text-sm font-medium transition-colors duration-200"
56
+ class="flex min-w-0 items-center justify-center font-normal transition-colors duration-200"
57
57
  :class="[
58
58
  // A group with no room left falls back to the tightest inset whatever the
59
59
  // density asks for, since the alternative is cutting into the names.
@@ -61,6 +61,9 @@
61
61
  // The active choice is the one worth reading at a glance, so it keeps its full
62
62
  // name and the rest give up the room a crowded group needs.
63
63
  selected[idx] ? 'shrink-0' : '',
64
+ // A group given a width shares the surplus between its buttons rather than
65
+ // leaving it as dead track behind them.
66
+ width ? 'grow' : '',
64
67
  selected[idx] ? 'text-white' : theme === 'dark' ? 'text-[#ffffff99]' : 'text-[#00000066]',
65
68
  selected[idx] && type === 'switch' ? 'bluevue-elevation-5' : '',
66
69
  disabled || btn.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
@@ -86,7 +89,7 @@
86
89
  <span
87
90
  data-bbg-label
88
91
  class="block truncate"
89
- :class="[density === 'compact' ? 'text-[10px]' : 'text-xs', isWide ? 'max-w-[150px]' : '']"
92
+ :class="[density === 'compact' ? 'text-[13px]' : 'text-base', isWide ? 'max-w-[150px]' : '']"
90
93
  >
91
94
  {{ btn.name }}
92
95
  </span>
@@ -205,6 +208,12 @@ const props = defineProps<{
205
208
  type: 'switch' | 'toggle'
206
209
  /** Height of the button group container */
207
210
  height?: string
211
+ /**
212
+ * Width of the button group container. The buttons share whatever it grants beyond their
213
+ * natural size, so the selected one still reaches the group's edge. It is a starting width
214
+ * rather than a fixed one, so a narrow row can still shrink the group.
215
+ */
216
+ width?: string
208
217
  /** Label text on the left side of the button group */
209
218
  label?: string
210
219
  /** Optional info tooltip shown via an info icon next to the control. */
@@ -246,9 +255,9 @@ let longPressFired = false
246
255
  // The inset each density gives a button name, paired with the total width it costs so the
247
256
  // measurements below can reason about the group without reading it back from the DOM.
248
257
  const DENSITY_PADDING = {
249
- compact: { class: 'px-[11px]', total: 22 },
250
- regular: { class: 'px-[15px]', total: 30 },
251
- comfortable: { class: 'px-[19px]', total: 38 },
258
+ compact: { class: 'px-[8px]', total: 16 },
259
+ regular: { class: 'px-[12px]', total: 24 },
260
+ comfortable: { class: 'px-[16px]', total: 32 },
252
261
  } as const
253
262
 
254
263
  // Once the buttons' natural width would exceed this, cap each label so long names
@@ -59,8 +59,8 @@ const toggle = (): void => {
59
59
  name="mdi-information-outline"
60
60
  :size="16"
61
61
  :label="infoTooltip"
62
- class="opacity-60 cursor-help"
63
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
62
+ class="cursor-help"
63
+ :class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
64
64
  />
65
65
  </BlueTooltip>
66
66
 
@@ -9,7 +9,7 @@ import BlueIcon from './BlueIcon.vue'
9
9
  * with an optional header, a body and an optional footer. Being a native modal, it dims the page
10
10
  * itself, traps focus, and closes on Escape and on the backdrop unless it is told not to.
11
11
  */
12
- const props = defineProps<{
12
+ const props = withDefaults(defineProps<{
13
13
  modelValue: boolean
14
14
  /** The heading. Without one, and without a header slot, the dialog is only its body. */
15
15
  title?: string
@@ -25,9 +25,30 @@ const props = defineProps<{
25
25
  persistent?: boolean
26
26
  /** Panel width (default '624px'). It never exceeds the viewport. */
27
27
  width?: string
28
+ /** Replaces the header's own inset, for a header holding a mark rather than a title row. */
29
+ headerClass?: string
28
30
  /** Replaces the body's own inset, for a dialog laying its contents out differently. */
29
31
  bodyClass?: string
30
- }>()
32
+ /** Replaces the footer's own inset. */
33
+ footerClass?: string
34
+ /**
35
+ * The rule under the header, on unless it is passed false. A header that reads as part of the
36
+ * body, such as a centred mark above a title, wants none.
37
+ */
38
+ headerDivider?: boolean
39
+ }>(), {
40
+ // An absent boolean prop is `false` unless a default says otherwise, which would leave every
41
+ // header unruled.
42
+ title: undefined,
43
+ icon: undefined,
44
+ iconColor: undefined,
45
+ subtitle: undefined,
46
+ width: undefined,
47
+ headerClass: undefined,
48
+ bodyClass: undefined,
49
+ footerClass: undefined,
50
+ headerDivider: undefined,
51
+ })
31
52
 
32
53
  const emit = defineEmits<{
33
54
  (event: 'update:modelValue', value: boolean): void
@@ -38,10 +59,18 @@ const dialogRef = ref<HTMLDialogElement | null>(null)
38
59
 
39
60
  const hasHeader = computed(() => Boolean(props.title || slots.header))
40
61
 
62
+ const hasHeaderDivider = computed(() => hasHeader.value && (props.headerDivider ?? true))
63
+
41
64
  const sync = (show: boolean): void => {
42
65
  const el = dialogRef.value
43
66
  if (!el) return
44
- if (show && !el.open) el.showModal()
67
+ if (show && !el.open) {
68
+ el.showModal()
69
+ // showModal() hands focus to the first control inside, which rings a button nobody asked for.
70
+ // Chromium ignores autofocus on the dialog itself, so the surface has to claim focus back:
71
+ // still inside the trap and still announced, with nothing preselected.
72
+ el.focus()
73
+ }
45
74
  if (!show && el.open) el.close()
46
75
  }
47
76
 
@@ -66,20 +95,23 @@ defineExpose({ close })
66
95
  </script>
67
96
 
68
97
  <template>
98
+ <!-- tabindex is what lets the surface hold the focus that sync() moves to it on open. -->
69
99
  <dialog
70
100
  ref="dialogRef"
101
+ tabindex="-1"
71
102
  class="bluevue-dialog"
72
103
  @close="emit('update:modelValue', false)"
73
104
  @cancel="onCancel"
74
105
  @click="onBackdropClick"
75
106
  >
76
107
  <div
77
- class="bluevue-panel relative max-w-full rounded-lg text-white"
108
+ class="bluevue-panel relative flex min-h-0 max-w-full flex-col rounded-lg text-white"
78
109
  :style="{ width: width || '624px' }"
79
110
  >
80
111
  <header
81
112
  v-if="hasHeader"
82
- class="flex items-start gap-3 px-5 pt-4 pb-3"
113
+ class="flex shrink-0 items-start gap-3"
114
+ :class="headerClass || 'px-5 pt-4 pb-3'"
83
115
  >
84
116
  <slot name="header">
85
117
  <BlueIcon
@@ -120,13 +152,20 @@ defineExpose({ close })
120
152
  @click="close"
121
153
  />
122
154
 
123
- <div :class="[bodyClass || 'px-5 py-4', hasHeader ? 'border-t border-[#ffffff0d]' : '']">
155
+ <div
156
+ :class="[
157
+ 'min-h-0 flex-1 overflow-y-auto',
158
+ bodyClass || 'px-5 py-4',
159
+ hasHeaderDivider ? 'border-t border-[var(--bluevue-hairline)]' : '',
160
+ ]"
161
+ >
124
162
  <slot />
125
163
  </div>
126
164
 
127
165
  <footer
128
166
  v-if="slots.footer"
129
- class="flex items-center justify-between gap-3 border-t border-[#ffffff0d] px-5 py-3"
167
+ class="flex shrink-0 items-center justify-between gap-3 border-t border-[var(--bluevue-hairline)]"
168
+ :class="footerClass || 'px-5 py-3'"
130
169
  >
131
170
  <slot name="footer" />
132
171
  </footer>
@@ -4,17 +4,22 @@
4
4
  class="flex items-center justify-between bg-transparent cursor-pointer select-none px-3 my-2"
5
5
  @click="togglePanel"
6
6
  >
7
- <div class="flex-1 flex items-center">
7
+ <div
8
+ class="flex-1 flex items-center"
9
+ :class="props.headerAlign === 'end' ? 'justify-end' : ''"
10
+ >
8
11
  <div
12
+ v-if="props.headerAlign !== 'end'"
9
13
  class="flex-1 h-px"
10
14
  :class="props.theme === 'dark' ? 'bg-[#ffffff22]' : 'bg-[#00000033]'"
11
15
  />
12
16
  <span
13
- class="mx-3 min-w-0 truncate text-sm font-medium opacity-60"
17
+ class="mx-3 min-w-0 truncate text-base font-normal opacity-60"
14
18
  :title="title"
15
19
  :class="props.theme === 'dark' ? 'text-white' : 'text-black'"
16
20
  >{{ title }}</span>
17
21
  <div
22
+ v-if="props.headerAlign !== 'end'"
18
23
  class="flex-1 h-px"
19
24
  :class="props.theme === 'dark' ? 'bg-[#ffffff11]' : 'bg-[#00000033]'"
20
25
  />
@@ -27,13 +32,28 @@
27
32
  ]"
28
33
  />
29
34
  </div>
30
- <transition name="bluevue-expand">
31
- <div
32
- v-show="isOpen"
33
- class="px-13 my-6 pb-2"
34
- :class="props.theme === 'dark' ? 'border-[#555555]' : 'border-[#dddddd]'"
35
- >
36
- <slot />
35
+ <transition
36
+ name="bluevue-expand"
37
+ @enter="openTo"
38
+ @leave="closeFrom"
39
+ @after-enter="releaseHeight"
40
+ @after-leave="releaseHeight"
41
+ @enter-cancelled="releaseHeight"
42
+ @leave-cancelled="releaseHeight"
43
+ >
44
+ <!-- The animated box holds no inset of its own. max-height cannot take an element below its
45
+ own padding, so padding here would strand the collapse at that height for the rest of
46
+ the run, and a margin would outlive it altogether. -->
47
+ <div v-show="isOpen">
48
+ <div
49
+ :class="[
50
+ 'flex flex-col gap-[15px]',
51
+ props.bodyClass || 'px-13 pt-4 pb-8',
52
+ props.theme === 'dark' ? 'border-[#555555]' : 'border-[#dddddd]',
53
+ ]"
54
+ >
55
+ <slot />
56
+ </div>
37
57
  </div>
38
58
  </transition>
39
59
  </div>
@@ -47,6 +67,17 @@ const props = defineProps<{
47
67
  title: string
48
68
  /** Initial open state */
49
69
  expanded?: boolean
70
+ /**
71
+ * Replaces the body's own inset, for a panel nested inside one that already insets its content.
72
+ * The column gap between the panel's children is kept either way. Use padding rather than
73
+ * margin: the body collapses by animating its own height, which a margin would not follow.
74
+ */
75
+ bodyClass?: string
76
+ /**
77
+ * 'full' (default) rules the title off across the panel; 'end' drops the rules and tucks the
78
+ * title against the right edge, for a secondary panel nested inside another one.
79
+ */
80
+ headerAlign?: 'full' | 'end'
50
81
  /** 'light' or 'dark' theme */
51
82
  theme?: 'light' | 'dark'
52
83
  }>()
@@ -57,6 +88,29 @@ const emit = defineEmits<{
57
88
 
58
89
  const isOpen = ref(props.expanded ?? true)
59
90
 
91
+ // max-height cannot animate to or from a keyword, so every open and close is driven from the
92
+ // panel's own measured height. A fixed ceiling instead would either clip a panel taller than it
93
+ // or open a short one in a fraction of the duration and then sit still for the rest.
94
+ const openTo = (el: Element): void => {
95
+ const panel = el as HTMLElement
96
+ panel.style.maxHeight = '0px'
97
+ const target = panel.scrollHeight
98
+ panel.getBoundingClientRect()
99
+ panel.style.maxHeight = `${target}px`
100
+ }
101
+
102
+ const closeFrom = (el: Element): void => {
103
+ const panel = el as HTMLElement
104
+ panel.style.maxHeight = `${panel.scrollHeight}px`
105
+ panel.getBoundingClientRect()
106
+ panel.style.maxHeight = '0px'
107
+ }
108
+
109
+ // Left in place, the cap would keep the panel from growing with whatever the slot renders next.
110
+ const releaseHeight = (el: Element): void => {
111
+ ;(el as HTMLElement).style.maxHeight = ''
112
+ }
113
+
60
114
  const togglePanel = (): void => {
61
115
  isOpen.value = !isOpen.value
62
116
  emit('update:expanded', isOpen.value)
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <header
3
+ class="flex w-full items-center justify-between rounded-t-[8px]"
4
+ :class="isDark ? 'text-white' : 'text-black'"
5
+ :style="{ minHeight: height || '56px', backgroundColor: background || defaultBackground }"
6
+ >
7
+ <!-- The rule runs under the whole cluster rather than under the selector alone, so the subject
8
+ of the panel reads as one field however many controls make it up. -->
9
+ <div
10
+ class="flex min-w-0 items-center gap-3 self-stretch border-b border-solid pl-4"
11
+ :class="[leadingWidth ? '' : 'flex-1', isDark ? 'border-[#ffffff88]' : 'border-[#00000066]']"
12
+ :style="leadingWidth ? { width: leadingWidth, maxWidth: '100%' } : undefined"
13
+ >
14
+ <slot name="leading" />
15
+ </div>
16
+ <div class="flex shrink-0 items-center gap-2 pr-4">
17
+ <slot />
18
+ </div>
19
+ </header>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import { computed } from 'vue'
24
+
25
+ /**
26
+ * The bar a panel opens with: what the panel is about on the left, and the controls that act on it
27
+ * on the right. It only lays the two out, so the subject can be a BlueHeaderSelector, a title, or
28
+ * anything else the panel names itself by.
29
+ */
30
+ const props = defineProps<{
31
+ /** How tall the bar may not go under (default '56px'). */
32
+ height?: string
33
+ /** Caps the leading cluster, so a long name cannot push the controls off the bar. */
34
+ leadingWidth?: string
35
+ /** Overrides the bar's own fill. */
36
+ background?: string
37
+ theme?: 'light' | 'dark'
38
+ }>()
39
+
40
+ const isDark = computed(() => props.theme !== 'light')
41
+ const defaultBackground = computed(() => (isDark.value ? '#15151577' : '#00000008'))
42
+ </script>
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <BlueMenu
3
+ :model-value="isOpen"
4
+ :items="items"
5
+ @update:model-value="(value: boolean) => (isOpen = value)"
6
+ >
7
+ <template #activator="{ props: activatorProps }">
8
+ <button
9
+ v-bind="activatorProps"
10
+ type="button"
11
+ :disabled="disabled"
12
+ :title="tooltip"
13
+ :aria-label="tooltip"
14
+ class="flex shrink-0 items-center justify-center rounded-[6px] transition-colors"
15
+ :class="[
16
+ disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
17
+ isDark ? 'text-[#ffffffcc] hover:text-white' : 'text-[#000000cc] hover:text-black',
18
+ ]"
19
+ :style="{ width: `${size}px`, height: `${size}px` }"
20
+ >
21
+ <span
22
+ class="mdi leading-none"
23
+ :class="isOpen ? 'mdi-menu-open' : 'mdi-menu-close'"
24
+ :style="{ fontSize: `${size}px` }"
25
+ />
26
+ </button>
27
+ </template>
28
+ </BlueMenu>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import { computed, ref } from 'vue'
33
+
34
+ import BlueMenu, { type BlueMenuItem } from './BlueMenu.vue'
35
+
36
+ /**
37
+ * The actions a header offers, behind the glyph that turns as they open. Sized for a header rather
38
+ * than for a row, which is why it is not an icon BlueButton.
39
+ */
40
+ const props = defineProps<{
41
+ items: BlueMenuItem[]
42
+ /** Edge of the square the glyph fills, in pixels (default 24). */
43
+ iconSize?: number
44
+ /** Hover text, and the name assistive technology reads. */
45
+ tooltip?: string
46
+ disabled?: boolean
47
+ theme?: 'light' | 'dark'
48
+ }>()
49
+
50
+ const isOpen = ref(false)
51
+ const isDark = computed(() => props.theme !== 'light')
52
+ const size = computed(() => props.iconSize ?? 24)
53
+ </script>
@@ -0,0 +1,153 @@
1
+ <template>
2
+ <div
3
+ ref="anchorRef"
4
+ class="min-w-0 flex-1"
5
+ >
6
+ <button
7
+ v-bind="activatorProps"
8
+ type="button"
9
+ :disabled="disabled"
10
+ class="group relative flex h-[56px] w-full min-w-0 flex-col justify-center bg-transparent pr-8 pl-4 text-left transition-colors"
11
+ :class="[
12
+ disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
13
+ isDark ? 'hover:bg-[#ffffff08]' : 'hover:bg-[#00000008]',
14
+ ]"
15
+ >
16
+ <span
17
+ v-if="label"
18
+ class="block truncate text-[12px] leading-none"
19
+ :class="isDark ? 'text-[#ffffffb3]' : 'text-[#00000099]'"
20
+ >{{ label }}</span>
21
+ <span
22
+ class="mt-[6px] flex min-w-0 items-center gap-1 text-[16px] leading-none"
23
+ :class="isDark ? 'text-white' : 'text-black'"
24
+ >
25
+ <span
26
+ v-if="selectedItem?.icon"
27
+ class="mdi shrink-0 text-[16px] leading-none opacity-70"
28
+ :class="selectedItem.icon"
29
+ />
30
+ <span class="truncate">{{ selectedItem?.name ?? placeholder }}</span>
31
+ </span>
32
+ <span
33
+ class="mdi mdi-menu-down absolute right-2 top-1/2 -translate-y-1/2 text-[24px] leading-none transition-transform"
34
+ :class="[isDark ? 'text-[#ffffffb3]' : 'text-[#00000099]', isOpen ? 'rotate-180' : '']"
35
+ />
36
+ </button>
37
+
38
+ <div
39
+ :id="popoverId"
40
+ ref="popoverRef"
41
+ popover
42
+ class="bluevue-popover"
43
+ :style="floatingStyles"
44
+ @toggle="onToggle"
45
+ >
46
+ <ul
47
+ class="bluevue-elevation-5 max-h-[60vh] overflow-y-auto rounded-[4px] border border-[#FFFFFF22]"
48
+ :class="[
49
+ isDark ? 'bg-[var(--bluevue-surface)]' : 'bg-white',
50
+ isPositioned ? 'opacity-100' : 'opacity-0',
51
+ ]"
52
+ >
53
+ <li
54
+ v-for="item in items"
55
+ :key="String(item.value ?? item.name)"
56
+ >
57
+ <button
58
+ type="button"
59
+ :disabled="item.disabled"
60
+ class="flex w-full items-start gap-2 px-4 py-2 text-left"
61
+ :class="[
62
+ isDark ? 'text-white' : 'text-black',
63
+ item.disabled
64
+ ? 'cursor-not-allowed opacity-30'
65
+ : isDark ? 'cursor-pointer hover:bg-[#ffffff11]' : 'cursor-pointer hover:bg-gray-100',
66
+ ]"
67
+ @click="selectItem(item)"
68
+ >
69
+ <span
70
+ v-if="item.icon"
71
+ class="mdi mt-[2px] shrink-0 text-[16px] leading-none opacity-70"
72
+ :class="item.icon"
73
+ />
74
+ <span class="min-w-0">
75
+ <span class="block truncate text-[14px] leading-snug">{{ item.name }}</span>
76
+ <span
77
+ v-if="item.subtitle"
78
+ class="block truncate text-[11px]"
79
+ :class="isDark ? 'text-[#ffffff66]' : 'text-[#00000066]'"
80
+ >{{ item.subtitle }}</span>
81
+ </span>
82
+ </button>
83
+ </li>
84
+ </ul>
85
+ </div>
86
+ </div>
87
+ </template>
88
+
89
+ <script setup lang="ts">
90
+ import { computed } from 'vue'
91
+
92
+ import { useBluePopover } from '../composables/useBluePopover'
93
+
94
+ /**
95
+ * One choice in the header selector.
96
+ */
97
+ export interface BlueHeaderSelectorItem {
98
+ /** What the row reads. */
99
+ name: string
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ value?: any
102
+ /** An mdi class marking the row, such as one still being looked for. */
103
+ icon?: string
104
+ /** A second line under the name, for what the row is rather than what it is called. */
105
+ subtitle?: string
106
+ disabled?: boolean
107
+ }
108
+
109
+ /**
110
+ * The subject a header is about, named above the value the way a filled form field is, so the
111
+ * heading and the choice occupy one control instead of a label and a dropdown side by side.
112
+ */
113
+ const props = defineProps<{
114
+ items: BlueHeaderSelectorItem[]
115
+ /** The name above the value. */
116
+ label?: string
117
+ /** Stands in for the value until something is chosen. */
118
+ placeholder?: string
119
+ disabled?: boolean
120
+ theme?: 'light' | 'dark'
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ modelValue?: any
123
+ }>()
124
+
125
+ const emit = defineEmits<{
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ (e: 'update:modelValue', value: any): void
128
+ }>()
129
+
130
+ const {
131
+ anchorRef,
132
+ popoverRef,
133
+ popoverId,
134
+ activatorProps,
135
+ isOpen,
136
+ isPositioned,
137
+ floatingStyles,
138
+ hide,
139
+ onToggle,
140
+ } = useBluePopover({ matchAnchorWidth: true })
141
+
142
+ const isDark = computed(() => props.theme !== 'light')
143
+
144
+ const selectedItem = computed(() =>
145
+ props.items.find((item) => (item.value ?? item.name) === props.modelValue)
146
+ )
147
+
148
+ const selectItem = (item: BlueHeaderSelectorItem): void => {
149
+ if (item.disabled) return
150
+ hide()
151
+ emit('update:modelValue', item.value ?? item.name)
152
+ }
153
+ </script>
@@ -11,7 +11,7 @@
11
11
  :for="name"
12
12
  class="block truncate text-start mr-6"
13
13
  :title="label"
14
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
14
+ :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
15
15
  >
16
16
  {{ label }}
17
17
  </label>
@@ -29,8 +29,8 @@
29
29
  name="mdi-information-outline"
30
30
  :size="16"
31
31
  :label="infoTooltip"
32
- class="opacity-60 cursor-help"
33
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
32
+ class="cursor-help"
33
+ :class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
34
34
  />
35
35
  </BlueTooltip>
36
36
 
@@ -37,7 +37,7 @@ const emit = defineEmits<{
37
37
  >
38
38
  <span
39
39
  v-if="message"
40
- class="mt-[15px] text-center text-base text-white"
40
+ class="mt-[5px] text-center text-base text-white"
41
41
  >
42
42
  {{ message }}
43
43
  </span>
@@ -28,8 +28,8 @@
28
28
  name="mdi-information-outline"
29
29
  :size="16"
30
30
  :label="infoTooltip"
31
- class="opacity-60 cursor-help"
32
- :class="theme === 'dark' ? 'text-white' : 'text-black'"
31
+ class="cursor-help"
32
+ :class="[disabled ? 'opacity-30' : 'opacity-60', theme === 'dark' ? 'text-white' : 'text-black']"
33
33
  />
34
34
  </BlueTooltip>
35
35