@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
@@ -22,20 +22,20 @@
22
22
  <!-- Shrinks far more eagerly than the label, so a tight row ellipsizes the button names
23
23
  before it starts eating into the label. -->
24
24
  <div class="flex items-center min-w-0 shrink-[1000]">
25
- <div
25
+ <BlueTooltip
26
26
  v-if="infoTooltip"
27
- class="relative group inline-flex items-center shrink-0 mr-2"
27
+ :text="infoTooltip"
28
+ :theme="theme"
29
+ class="items-center shrink-0 mr-2"
28
30
  >
29
- <span
30
- class="mdi mdi-information-outline text-[16px] opacity-60 cursor-help"
31
+ <BlueIcon
32
+ name="mdi-information-outline"
33
+ :size="16"
34
+ :label="infoTooltip"
35
+ class="opacity-60 cursor-help"
31
36
  :class="theme === 'dark' ? 'text-white' : 'text-black'"
32
37
  />
33
- <div
34
- 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]"
35
- >
36
- {{ infoTooltip }}
37
- </div>
38
- </div>
38
+ </BlueTooltip>
39
39
  <div
40
40
  ref="trackRef"
41
41
  class="relative flex min-w-0 justify-end overflow-hidden rounded-[6px] bluevue-elevation-1 z-[666]"
@@ -76,21 +76,21 @@
76
76
  @pointerleave="cancelLongPress"
77
77
  @pointercancel="cancelLongPress"
78
78
  >
79
- <div class="relative group inline-block min-w-0">
80
- <p
79
+ <BlueTooltip
80
+ :text="btn.tooltip || ''"
81
+ :theme="theme"
82
+ class="min-w-0"
83
+ >
84
+ <!-- A span rather than a paragraph: this is one line inside a button, and a
85
+ paragraph brings margins of its own wherever the host page has no reset. -->
86
+ <span
81
87
  data-bbg-label
82
- class="truncate"
88
+ class="block truncate"
83
89
  :class="[density === 'compact' ? 'text-[10px]' : 'text-xs', isWide ? 'max-w-[150px]' : '']"
84
90
  >
85
91
  {{ btn.name }}
86
- </p>
87
- <div
88
- v-if="btn.tooltip"
89
- class="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:block whitespace-no-wrap px-2 py-1 text-xs rounded bg-gray-700 text-white"
90
- >
91
- {{ btn.tooltip }}
92
- </div>
93
- </div>
92
+ </span>
93
+ </BlueTooltip>
94
94
  </button>
95
95
  <div
96
96
  v-if="idx < buttonItems.length - 1"
@@ -156,6 +156,8 @@
156
156
  import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
157
157
 
158
158
  import { useBluePopover } from '../composables/useBluePopover'
159
+ import BlueIcon from './BlueIcon.vue'
160
+ import BlueTooltip from './BlueTooltip.vue'
159
161
 
160
162
  /**
161
163
  * One button in the group
@@ -244,9 +246,9 @@ let longPressFired = false
244
246
  // The inset each density gives a button name, paired with the total width it costs so the
245
247
  // measurements below can reason about the group without reading it back from the DOM.
246
248
  const DENSITY_PADDING = {
247
- compact: { class: 'px-2', total: 16 },
248
- regular: { class: 'px-3', total: 24 },
249
- comfortable: { class: 'px-4', total: 32 },
249
+ compact: { class: 'px-[11px]', total: 22 },
250
+ regular: { class: 'px-[15px]', total: 30 },
251
+ comfortable: { class: 'px-[19px]', total: 38 },
250
252
  } as const
251
253
 
252
254
  // Once the buttons' natural width would exceed this, cap each label so long names
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import BlueIcon from './BlueIcon.vue'
3
+
4
+ /**
5
+ * The sheet an extension's page is laid out on: a titled bar over a body, raised off the backdrop.
6
+ * BlueApp puts one in the middle of the page for you; reach for it directly when a page needs more
7
+ * than one.
8
+ */
9
+ defineProps<{
10
+ title?: string
11
+ /** An image beside the title, such as the extension's own mark. */
12
+ logo?: string
13
+ /** An mdi name beside the title, for a card with no mark of its own. */
14
+ icon?: string
15
+ /** How wide the card is allowed to get (default '615px'). */
16
+ width?: string
17
+ /** Replaces the body's own inset, for a body that lays itself out. */
18
+ bodyClass?: string
19
+ }>()
20
+ </script>
21
+
22
+ <template>
23
+ <section
24
+ class="bluevue-elevation-5 mx-auto w-full rounded-[8px] bg-[var(--bluevue-surface)] text-white"
25
+ :style="{ maxWidth: width || '615px' }"
26
+ >
27
+ <!-- Relative, so the bar's own shadow paints over the body below it rather than under it. -->
28
+ <header
29
+ v-if="title || $slots.header || $slots.actions"
30
+ class="bluevue-elevation-1 relative flex items-center gap-5 rounded-t-[8px] bg-[#15151577] px-5 py-3"
31
+ >
32
+ <slot name="header">
33
+ <img
34
+ v-if="logo"
35
+ :src="logo"
36
+ alt=""
37
+ class="h-6 w-6 shrink-0"
38
+ >
39
+ <BlueIcon
40
+ v-else-if="icon"
41
+ :name="icon"
42
+ :size="24"
43
+ />
44
+ <span class="truncate text-lg font-medium">{{ title }}</span>
45
+ </slot>
46
+ <div class="ml-auto flex shrink-0 items-center gap-1">
47
+ <slot name="actions" />
48
+ </div>
49
+ </header>
50
+
51
+ <div :class="bodyClass || 'px-5 py-4'">
52
+ <slot />
53
+ </div>
54
+
55
+ <footer
56
+ v-if="$slots.footer"
57
+ class="flex items-center justify-between gap-3 border-t border-[#ffffff0d] px-5 py-3"
58
+ >
59
+ <slot name="footer" />
60
+ </footer>
61
+ </section>
62
+ </template>
@@ -0,0 +1,100 @@
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 plain yes or no, laid out as the other controls are: what it is on the left, the box on the
9
+ * right. A native checkbox drives it, so it is reached and toggled from the keyboard as one.
10
+ */
11
+ const props = defineProps<{
12
+ modelValue: boolean | null
13
+ /** Label on the left of the row. */
14
+ label?: string
15
+ /** Name and id, tying the label to the box. */
16
+ name: string
17
+ /** Neither on nor off, for a box standing for several things that disagree. */
18
+ indeterminate?: boolean
19
+ disabled?: boolean
20
+ /** The fill when it is on (default the primary token). */
21
+ color?: string
22
+ /** A hint behind an info icon beside the box. */
23
+ infoTooltip?: string
24
+ theme?: 'light' | 'dark'
25
+ }>()
26
+
27
+ const emit = defineEmits<{
28
+ (event: 'update:modelValue', value: boolean): void
29
+ }>()
30
+
31
+ const checked = computed(() => props.modelValue === true)
32
+
33
+ const toggle = (): void => {
34
+ if (props.disabled) return
35
+ // An indeterminate box is answering for several things at once, and the answer it takes is yes.
36
+ emit('update:modelValue', props.indeterminate ? true : !checked.value)
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div class="flex w-full items-center justify-between">
42
+ <label
43
+ v-if="label"
44
+ :for="name"
45
+ class="min-w-0 max-w-[45%] shrink-0 truncate text-start mr-6"
46
+ :title="label"
47
+ :class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : 'cursor-pointer']"
48
+ >{{ label }}</label>
49
+ <div v-else />
50
+
51
+ <div class="ml-auto flex shrink-0 items-center">
52
+ <BlueTooltip
53
+ v-if="infoTooltip"
54
+ :text="infoTooltip"
55
+ :theme="theme"
56
+ class="items-center mr-2"
57
+ >
58
+ <BlueIcon
59
+ name="mdi-information-outline"
60
+ :size="16"
61
+ :label="infoTooltip"
62
+ class="opacity-60 cursor-help"
63
+ :class="theme === 'dark' ? 'text-white' : 'text-black'"
64
+ />
65
+ </BlueTooltip>
66
+
67
+ <!-- The native box is the control; the square beside it is what is actually seen, and the
68
+ two are one element to the pointer and to the keyboard because the label wraps both. -->
69
+ <label
70
+ class="relative inline-flex items-center"
71
+ :class="disabled ? 'opacity-30' : 'cursor-pointer'"
72
+ >
73
+ <input
74
+ :id="name"
75
+ type="checkbox"
76
+ class="peer absolute h-0 w-0 opacity-0"
77
+ :name="name"
78
+ :checked="checked"
79
+ :disabled="disabled"
80
+ :aria-checked="indeterminate ? 'mixed' : checked"
81
+ @change="toggle"
82
+ >
83
+ <span
84
+ class="bluevue-elevation-1 flex h-[18px] w-[18px] items-center justify-center rounded-[4px] border transition-colors peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--bluevue-accent)]"
85
+ :class="[
86
+ checked || indeterminate ? 'border-transparent' : theme === 'dark' ? 'border-[#ffffff44] bg-[#00000022]' : 'border-[#00000044] bg-white',
87
+ ]"
88
+ :style="{ backgroundColor: checked || indeterminate ? color || 'var(--bluevue-primary)' : undefined }"
89
+ >
90
+ <BlueIcon
91
+ v-if="checked || indeterminate"
92
+ :name="indeterminate ? 'mdi-minus' : 'mdi-check'"
93
+ :size="14"
94
+ color="#ffffff"
95
+ />
96
+ </span>
97
+ </label>
98
+ </div>
99
+ </div>
100
+ </template>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ import BlueIcon from './BlueIcon.vue'
5
+
6
+ /**
7
+ * A small standing label: a state, a tag, a count. It says what something is rather than doing
8
+ * anything, so anything that acts on a press belongs in a BlueButton instead.
9
+ */
10
+ const props = defineProps<{
11
+ /** The text. A default slot takes its place when given. */
12
+ label?: string
13
+ /** 'tonal' (default) is a fill of the colour at low alpha, 'filled' the colour itself. */
14
+ variant?: 'tonal' | 'filled' | 'outlined'
15
+ /** Any CSS colour, or one of the standard meanings. */
16
+ color?: 'success' | 'error' | 'warning' | 'info' | 'neutral' | string
17
+ /** An mdi name before the text. */
18
+ icon?: string
19
+ /** Offers a cross that emits close. */
20
+ closable?: boolean
21
+ size?: 'small' | 'regular'
22
+ }>()
23
+
24
+ defineEmits<{
25
+ (event: 'close'): void
26
+ }>()
27
+
28
+ const MEANING: Record<string, string> = {
29
+ success: '#66BB6A',
30
+ error: '#EF5350',
31
+ warning: '#FFB74D',
32
+ info: '#42A5F5',
33
+ neutral: '#90A4AE',
34
+ }
35
+
36
+ const tone = computed(() => MEANING[props.color ?? 'neutral'] ?? props.color ?? MEANING.neutral)
37
+
38
+ const filled = computed(() => props.variant === 'filled')
39
+
40
+ // A tonal chip is the tone at a tenth of itself, which reads as a tint of the surface rather than
41
+ // as something sitting on top of it. Colour-mix rather than an alpha suffix, since the tone may
42
+ // arrive as any CSS colour, including a variable.
43
+ const look = computed(() => ({
44
+ color: filled.value ? '#ffffff' : tone.value,
45
+ backgroundColor: filled.value ? tone.value : props.variant === 'outlined' ? 'transparent' : `color-mix(in srgb, ${tone.value} 18%, transparent)`,
46
+ borderColor: props.variant === 'outlined' ? tone.value : 'transparent',
47
+ }))
48
+ </script>
49
+
50
+ <template>
51
+ <span
52
+ class="inline-flex max-w-full shrink-0 items-center gap-1 whitespace-nowrap rounded-full border"
53
+ :class="size === 'small' ? 'h-[18px] px-2 text-[11px]' : 'h-[22px] px-[10px] text-[12px]'"
54
+ :style="look"
55
+ >
56
+ <BlueIcon
57
+ v-if="icon"
58
+ :name="icon"
59
+ :size="size === 'small' ? 12 : 14"
60
+ />
61
+ <span class="min-w-0 truncate">
62
+ <slot>{{ label }}</slot>
63
+ </span>
64
+ <button
65
+ v-if="closable"
66
+ type="button"
67
+ class="-mr-1 cursor-pointer opacity-70 transition-opacity hover:opacity-100"
68
+ :aria-label="`Remove ${label}`"
69
+ @click="$emit('close')"
70
+ >
71
+ <BlueIcon
72
+ name="mdi-close"
73
+ :size="size === 'small' ? 12 : 14"
74
+ />
75
+ </button>
76
+ </span>
77
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+
4
+ import BlueButton from './BlueButton.vue'
5
+ import BlueDialog from './BlueDialog.vue'
6
+
7
+ /**
8
+ * Asks before something that cannot be taken back. The question is the title, what it will cost
9
+ * is the message, and the committing button says what it does rather than saying yes.
10
+ */
11
+ defineProps<{
12
+ modelValue: boolean
13
+ title: string
14
+ /** What confirming will do, and to what. */
15
+ message?: string
16
+ /** Names the action rather than agreeing with the question: 'Delete', 'Restart', 'Overwrite'. */
17
+ confirmLabel?: string
18
+ cancelLabel?: string
19
+ /** Colours the committing button as a warning, for anything destructive. */
20
+ danger?: boolean
21
+ icon?: string
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ (event: 'update:modelValue', value: boolean): void
26
+ (event: 'confirm'): void
27
+ }>()
28
+
29
+ const dialogRef = ref<InstanceType<typeof BlueDialog> | null>(null)
30
+
31
+ function confirm(): void {
32
+ dialogRef.value?.close()
33
+ emit('confirm')
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <BlueDialog
39
+ ref="dialogRef"
40
+ :model-value="modelValue"
41
+ :title="title"
42
+ :icon="icon || (danger ? 'mdi-alert-outline' : 'mdi-help-circle-outline')"
43
+ :icon-color="danger ? 'var(--bluevue-error)' : undefined"
44
+ width="440px"
45
+ @update:model-value="emit('update:modelValue', $event)"
46
+ >
47
+ <p
48
+ v-if="message"
49
+ class="text-sm leading-relaxed text-[#ffffffcc]"
50
+ >
51
+ {{ message }}
52
+ </p>
53
+ <slot />
54
+
55
+ <template #footer>
56
+ <BlueButton
57
+ variant="text"
58
+ density="compact"
59
+ @click="dialogRef?.close()"
60
+ >
61
+ {{ cancelLabel || 'Cancel' }}
62
+ </BlueButton>
63
+ <BlueButton
64
+ variant="filled"
65
+ density="compact"
66
+ :color="danger ? 'var(--bluevue-error)' : undefined"
67
+ @click="confirm"
68
+ >
69
+ {{ confirmLabel || 'Confirm' }}
70
+ </BlueButton>
71
+ </template>
72
+ </BlueDialog>
73
+ </template>
@@ -0,0 +1,135 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, useSlots, watch } from 'vue'
3
+
4
+ import BlueButton from './BlueButton.vue'
5
+ import BlueIcon from './BlueIcon.vue'
6
+
7
+ /**
8
+ * The surface every dialog in the kit is built on: a frosted panel in the browser's top layer,
9
+ * with an optional header, a body and an optional footer. Being a native modal, it dims the page
10
+ * itself, traps focus, and closes on Escape and on the backdrop unless it is told not to.
11
+ */
12
+ const props = defineProps<{
13
+ modelValue: boolean
14
+ /** The heading. Without one, and without a header slot, the dialog is only its body. */
15
+ title?: string
16
+ /** An mdi name beside the title. */
17
+ icon?: string
18
+ iconColor?: string
19
+ /** A line under the title, for what the dialog is about to act on. */
20
+ subtitle?: string
21
+ /**
22
+ * Takes away Escape, the backdrop and the close button. For an operation that must not end up
23
+ * running under a live page.
24
+ */
25
+ persistent?: boolean
26
+ /** Panel width (default '624px'). It never exceeds the viewport. */
27
+ width?: string
28
+ /** Replaces the body's own inset, for a dialog laying its contents out differently. */
29
+ bodyClass?: string
30
+ }>()
31
+
32
+ const emit = defineEmits<{
33
+ (event: 'update:modelValue', value: boolean): void
34
+ }>()
35
+
36
+ const slots = useSlots()
37
+ const dialogRef = ref<HTMLDialogElement | null>(null)
38
+
39
+ const hasHeader = computed(() => Boolean(props.title || slots.header))
40
+
41
+ const sync = (show: boolean): void => {
42
+ const el = dialogRef.value
43
+ if (!el) return
44
+ if (show && !el.open) el.showModal()
45
+ if (!show && el.open) el.close()
46
+ }
47
+
48
+ onMounted(() => sync(props.modelValue))
49
+ watch(() => props.modelValue, sync)
50
+
51
+ // Every way out closes the element, and its close event is what reports back, so Escape and the
52
+ // backdrop need no separate answer.
53
+ const close = (): void => dialogRef.value?.close()
54
+
55
+ const onCancel = (event: Event): void => {
56
+ if (props.persistent) event.preventDefault()
57
+ }
58
+
59
+ // A click landing on the dialog itself landed on the backdrop, since the panel covers the whole of
60
+ // the element's own box.
61
+ const onBackdropClick = (event: MouseEvent): void => {
62
+ if (!props.persistent && event.target === dialogRef.value) close()
63
+ }
64
+
65
+ defineExpose({ close })
66
+ </script>
67
+
68
+ <template>
69
+ <dialog
70
+ ref="dialogRef"
71
+ class="bluevue-dialog"
72
+ @close="emit('update:modelValue', false)"
73
+ @cancel="onCancel"
74
+ @click="onBackdropClick"
75
+ >
76
+ <div
77
+ class="bluevue-panel relative max-w-full rounded-lg text-white"
78
+ :style="{ width: width || '624px' }"
79
+ >
80
+ <header
81
+ v-if="hasHeader"
82
+ class="flex items-start gap-3 px-5 pt-4 pb-3"
83
+ >
84
+ <slot name="header">
85
+ <BlueIcon
86
+ v-if="icon"
87
+ :name="icon"
88
+ :size="22"
89
+ :color="iconColor || '#42A5F5'"
90
+ class="mt-[2px]"
91
+ />
92
+ <div class="min-w-0 flex-1">
93
+ <div class="truncate text-base leading-tight">
94
+ {{ title }}
95
+ </div>
96
+ <div
97
+ v-if="subtitle"
98
+ class="mt-1 text-xs leading-relaxed text-[#ffffff88]"
99
+ >
100
+ {{ subtitle }}
101
+ </div>
102
+ </div>
103
+ </slot>
104
+ <BlueButton
105
+ v-if="!persistent"
106
+ variant="icon"
107
+ icon="mdi-close"
108
+ tooltip="Close"
109
+ @click="close"
110
+ />
111
+ </header>
112
+
113
+ <!-- With no header to hold it, the way out sits over the corner of the panel. -->
114
+ <BlueButton
115
+ v-else-if="!persistent"
116
+ variant="icon"
117
+ icon="mdi-close"
118
+ tooltip="Close"
119
+ class="absolute right-3 top-3 z-10"
120
+ @click="close"
121
+ />
122
+
123
+ <div :class="[bodyClass || 'px-5 py-4', hasHeader ? 'border-t border-[#ffffff0d]' : '']">
124
+ <slot />
125
+ </div>
126
+
127
+ <footer
128
+ v-if="slots.footer"
129
+ class="flex items-center justify-between gap-3 border-t border-[#ffffff0d] px-5 py-3"
130
+ >
131
+ <slot name="footer" />
132
+ </footer>
133
+ </div>
134
+ </dialog>
135
+ </template>
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+
4
+ import BlueIcon from './BlueIcon.vue'
5
+
6
+ /**
7
+ * A place to drop a file on, which is also a place to press for the file picker. It hands the
8
+ * files over and keeps none of them, so what is done with one, and whether it is any good, stays
9
+ * with the consumer.
10
+ */
11
+ const props = defineProps<{
12
+ /** What the picker offers, as the accept attribute takes it (e.g. '.json,.param'). */
13
+ accept?: string
14
+ /** Takes more than one file at a time. */
15
+ multiple?: boolean
16
+ /** The line under the invitation, usually naming the formats that are understood. */
17
+ hint?: string
18
+ /** The invitation itself. */
19
+ label?: string
20
+ disabled?: boolean
21
+ theme?: 'light' | 'dark'
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ (event: 'files', files: File[]): void
26
+ }>()
27
+
28
+ const input = ref<HTMLInputElement>()
29
+ const hovering = ref(false)
30
+ // Dragging over a child fires leave on the parent, so the crossings are counted rather than
31
+ // treated as one, and the zone only cools down when the pointer has really left it.
32
+ const depth = ref(0)
33
+
34
+ const take = (files: FileList | null): void => {
35
+ if (props.disabled || !files?.length) return
36
+ emit('files', Array.from(files))
37
+ }
38
+
39
+ const onDrop = (event: DragEvent): void => {
40
+ depth.value = 0
41
+ hovering.value = false
42
+ take(event.dataTransfer?.files ?? null)
43
+ }
44
+
45
+ const onPick = (event: Event): void => {
46
+ const element = event.target as HTMLInputElement
47
+ take(element.files)
48
+ // Cleared so picking the same file twice in a row is still an event the second time.
49
+ element.value = ''
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div
55
+ class="w-full"
56
+ :class="disabled ? 'pointer-events-none opacity-30' : ''"
57
+ >
58
+ <button
59
+ type="button"
60
+ class="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-[8px] border border-dashed px-4 py-6 text-center transition-colors"
61
+ :class="[
62
+ hovering
63
+ ? 'border-[var(--bluevue-accent)] bg-[var(--bluevue-accent)]/10'
64
+ : theme === 'light' ? 'border-[#00000033] hover:bg-[#00000006]' : 'border-[#ffffff33] hover:bg-[#ffffff08]',
65
+ theme === 'light' ? 'text-black' : 'text-white',
66
+ ]"
67
+ @click="input?.click()"
68
+ @dragenter.prevent="depth += 1; hovering = true"
69
+ @dragover.prevent
70
+ @dragleave.prevent="depth -= 1; hovering = depth > 0"
71
+ @drop.prevent="onDrop"
72
+ >
73
+ <BlueIcon
74
+ name="mdi-tray-arrow-up"
75
+ :size="24"
76
+ class="opacity-70"
77
+ />
78
+ <span class="text-[13px]">{{ label ?? 'Drop a file here, or press to choose one' }}</span>
79
+ <span
80
+ v-if="hint"
81
+ class="text-[11px] opacity-60"
82
+ >{{ hint }}</span>
83
+ </button>
84
+
85
+ <input
86
+ ref="input"
87
+ type="file"
88
+ class="hidden"
89
+ :accept="accept"
90
+ :multiple="multiple"
91
+ @change="onPick"
92
+ >
93
+ </div>
94
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ /**
5
+ * A Material Design Icon in a square box, so a row of them lines up whichever glyphs it carries
6
+ * and however wide each one happens to advance.
7
+ */
8
+ const props = defineProps<{
9
+ /** Icon name, with or without the `mdi-` prefix. */
10
+ name: string
11
+ /** Side of the box, in pixels when given as a number (default 20). */
12
+ size?: number | string
13
+ color?: string
14
+ /** Turns, for something under way. */
15
+ spin?: boolean
16
+ /** Read out in place of the icon. Without one the icon is hidden from assistive technology. */
17
+ label?: string
18
+ }>()
19
+
20
+ const iconClass = computed(() => (props.name.startsWith('mdi-') ? props.name : `mdi-${props.name}`))
21
+
22
+ const box = computed(() => {
23
+ const size = props.size ?? 20
24
+ return typeof size === 'number' ? `${size}px` : size
25
+ })
26
+ </script>
27
+
28
+ <template>
29
+ <span
30
+ class="mdi bluevue-icon"
31
+ :class="[iconClass, spin ? 'bluevue-icon--spin' : '']"
32
+ :style="{ width: box, height: box, fontSize: box, color }"
33
+ :role="label ? 'img' : undefined"
34
+ :aria-label="label"
35
+ :aria-hidden="label ? undefined : 'true'"
36
+ />
37
+ </template>