@morscherlab/mint-sdk 1.2.0 → 1.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.
- package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
- package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
- package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DELMkUFR.js} +39 -16
- package/dist/SettingsModal-DELMkUFR.js.map +1 -0
- package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
- package/dist/Skeleton-jhF9wUkU.js.map +1 -0
- package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
- package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
- package/dist/__tests__/components/FormField.test.d.ts +1 -0
- package/dist/__tests__/components/LayoutResizeHandle.test.d.ts +1 -0
- package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
- package/dist/__tests__/components/SearchableSelect.test.d.ts +1 -0
- package/dist/__tests__/components/WellPlate.decoration.test.d.ts +1 -0
- package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
- package/dist/__tests__/composables/useManualLayoutResize.test.d.ts +1 -0
- package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
- package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
- package/dist/components/AdductText.vue.d.ts +17 -0
- package/dist/components/AppSidebar.vue.d.ts +7 -7
- package/dist/components/BasePill.vue.d.ts +3 -0
- package/dist/components/BaseTabs.vue.d.ts +1 -4
- package/dist/components/ChartContainer.vue.d.ts +19 -5
- package/dist/components/ChemicalFormula.vue.d.ts +28 -2
- package/dist/components/DataFrame.vue.d.ts +1 -1
- package/dist/components/FileUploader.vue.d.ts +3 -0
- package/dist/components/FormField.vue.d.ts +8 -1
- package/dist/components/LayoutResizeHandle.vue.d.ts +23 -0
- package/dist/components/PlotlyChart.vue.d.ts +34 -6
- package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
- package/dist/components/SearchableSelect.vue.d.ts +57 -0
- package/dist/components/WellPlate.vue.d.ts +5 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.js +6 -6
- package/dist/{components-DIum5hkx.js → components-CxQVQCpP.js} +1215 -546
- package/dist/components-CxQVQCpP.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +8 -8
- package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
- package/dist/composables/useManualLayoutResize.d.ts +56 -0
- package/dist/composables/useToast.d.ts +22 -1
- package/dist/{composables-mFXsTA4g.js → composables-DAqOU8Aq.js} +97 -8
- package/dist/composables-DAqOU8Aq.js.map +1 -0
- package/dist/index.js +11 -11
- package/dist/install.js +2 -2
- package/dist/styles.css +855 -320
- package/dist/templates/index.js +2 -2
- package/dist/{templates-CmGfmBRA.js → templates-NuaNHohp.js} +2 -2
- package/dist/{templates-CmGfmBRA.js.map → templates-NuaNHohp.js.map} +1 -1
- package/dist/types/componentLabTypes.d.ts +3 -0
- package/dist/types/components.d.ts +26 -1
- package/dist/types/form-builder.d.ts +2 -0
- package/dist/types/generated-ui.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-vLiRPUmj.js} +181 -71
- package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
- package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
- package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
- package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-D37ajgS8.js} +2 -2
- package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
- package/dist/{validation-uwoJGEb6.js → validation-ChMyixAf.js} +21 -9
- package/dist/validation-ChMyixAf.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppSidebar.test.ts +84 -94
- package/src/__tests__/components/AppToastContainer.test.ts +55 -1
- package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
- package/src/__tests__/components/ChartContainer.test.ts +24 -0
- package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
- package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
- package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
- package/src/__tests__/components/FormBuilder.test.ts +26 -0
- package/src/__tests__/components/FormField.test.ts +26 -0
- package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
- package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
- package/src/__tests__/components/NumberInput.test.ts +149 -0
- package/src/__tests__/components/PlotlyChart.test.ts +48 -0
- package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
- package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
- package/src/__tests__/components/RackEditor.test.ts +9 -0
- package/src/__tests__/components/SearchableSelect.test.ts +61 -0
- package/src/__tests__/components/WellPlate.decoration.test.ts +28 -0
- package/src/__tests__/composables/localFiles.test.ts +57 -0
- package/src/__tests__/composables/useManualLayoutResize.test.ts +21 -0
- package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
- package/src/__tests__/generatedUi.test.ts +111 -0
- package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
- package/src/components/AdductText.story.vue +15 -0
- package/src/components/AdductText.vue +40 -0
- package/src/components/AppLayout.story.vue +1 -1
- package/src/components/AppSidebar.story.vue +7 -10
- package/src/components/AppSidebar.vue +178 -58
- package/src/components/AppToastContainer.vue +65 -23
- package/src/components/BasePill.story.vue +2 -0
- package/src/components/BasePill.vue +4 -0
- package/src/components/BaseTabs.story.vue +0 -42
- package/src/components/BaseTabs.vue +3 -6
- package/src/components/ChartContainer.story.vue +21 -0
- package/src/components/ChartContainer.vue +27 -6
- package/src/components/ChemicalFormula.story.vue +7 -0
- package/src/components/ChemicalFormula.vue +40 -11
- package/src/components/FileUploader.vue +10 -1
- package/src/components/FormField.vue +10 -3
- package/src/components/GeneratedPathInput.vue +11 -7
- package/src/components/LayoutResizeHandle.story.vue +26 -0
- package/src/components/LayoutResizeHandle.vue +62 -0
- package/src/components/NumberInput.story.vue +32 -13
- package/src/components/NumberInput.vue +145 -56
- package/src/components/PlotlyChart.vue +77 -11
- package/src/components/PluginWorkspaceView.props.ts +3 -3
- package/src/components/RackEditor.vue +38 -32
- package/src/components/SearchableSelect.story.vue +24 -0
- package/src/components/SearchableSelect.vue +223 -0
- package/src/components/WellPlate.story.vue +16 -0
- package/src/components/WellPlate.vue +17 -8
- package/src/components/index.ts +3 -1
- package/src/components/internal/FormFieldRendererInternal.vue +17 -0
- package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
- package/src/composables/filePicker/localFiles.ts +21 -14
- package/src/composables/filePicker/usePickerSelection.ts +15 -4
- package/src/composables/index.ts +10 -0
- package/src/composables/pluginWorkspaceModel.ts +0 -3
- package/src/composables/useManualLayoutResize.ts +165 -0
- package/src/composables/usePluginWorkspace.ts +1 -2
- package/src/composables/useToast.ts +21 -5
- package/src/generated/schema.ts +58 -18
- package/src/styles/components/adduct-text.css +45 -0
- package/src/styles/components/app-sidebar.css +282 -121
- package/src/styles/components/chart-container.css +92 -0
- package/src/styles/components/chemical-formula.css +34 -4
- package/src/styles/components/form-builder.css +1 -1
- package/src/styles/components/form-field.css +28 -0
- package/src/styles/components/layout-resize-handle.css +60 -0
- package/src/styles/components/number-input.css +75 -91
- package/src/styles/components/pill.css +49 -51
- package/src/styles/components/rack-editor.css +31 -9
- package/src/styles/components/searchable-select.css +63 -0
- package/src/styles/components/segmented-control.css +16 -11
- package/src/styles/components/tabs.css +18 -34
- package/src/styles/components/toast.css +136 -27
- package/src/styles/components/well-plate.css +9 -0
- package/src/styles/index.css +3 -0
- package/src/styles/variables.css +25 -5
- package/src/types/componentLabTypes.ts +6 -0
- package/src/types/components.ts +30 -0
- package/src/types/form-builder.ts +2 -0
- package/src/types/generated-ui.ts +2 -0
- package/src/types/index.ts +5 -0
- package/dist/SettingsModal-BYR20I4c.js.map +0 -1
- package/dist/Skeleton-BAF3CKY7.js.map +0 -1
- package/dist/components-DIum5hkx.js.map +0 -1
- package/dist/composables-mFXsTA4g.js.map +0 -1
- package/dist/useControlSchema-DldpxNOP.js.map +0 -1
- package/dist/validation-uwoJGEb6.js.map +0 -1
|
@@ -259,51 +259,57 @@ const activeRackWells = computed(() => editor.activeRack.value?.wells ?? {})
|
|
|
259
259
|
<!-- Rack tabs -->
|
|
260
260
|
<div class="mint-rack-editor__tabs">
|
|
261
261
|
<div class="mint-rack-editor__tabs-inner">
|
|
262
|
-
<
|
|
262
|
+
<div
|
|
263
263
|
v-for="(rack, rackIndex) in editor.racks.value"
|
|
264
264
|
:key="rack.id"
|
|
265
|
-
|
|
266
|
-
:draggable="allowReorder && !readonly"
|
|
267
|
-
:title="allowReorder && !readonly ? `${rack.name} — press Alt with the arrow keys to reorder` : undefined"
|
|
268
|
-
:class="[
|
|
269
|
-
'mint-rack-editor__tab',
|
|
270
|
-
{
|
|
271
|
-
'mint-rack-editor__tab--active': editor.activeRackId.value === rack.id,
|
|
272
|
-
'mint-rack-editor__tab--dragging': draggingRackId === rack.id,
|
|
273
|
-
'mint-rack-editor__tab--drag-over': dragOverRackId === rack.id,
|
|
274
|
-
},
|
|
275
|
-
]"
|
|
276
|
-
@click="handleTabClick(rack.id)"
|
|
277
|
-
@keydown="onRackReorderKeydown($event, rack, rackIndex)"
|
|
278
|
-
@dragstart="onRackDragStart($event, rack, rackIndex)"
|
|
279
|
-
@dragover="onRackDragOver($event, rack, rackIndex)"
|
|
280
|
-
@dragleave="onRackDragLeave"
|
|
281
|
-
@drop="onRackDrop($event, rack, rackIndex)"
|
|
282
|
-
@dragend="onRackDragEnd"
|
|
265
|
+
class="mint-rack-editor__tab-item"
|
|
283
266
|
>
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
<span
|
|
290
|
-
v-if="getWellCount(rack) > 0"
|
|
267
|
+
<button
|
|
268
|
+
:ref="(el) => setTabRef(rack.id, el)"
|
|
269
|
+
type="button"
|
|
270
|
+
:draggable="allowReorder && !readonly"
|
|
271
|
+
:title="allowReorder && !readonly ? `${rack.name} — press Alt with the arrow keys to reorder` : undefined"
|
|
291
272
|
:class="[
|
|
292
|
-
'mint-rack-
|
|
293
|
-
|
|
273
|
+
'mint-rack-editor__tab',
|
|
274
|
+
{
|
|
275
|
+
'mint-rack-editor__tab--active': editor.activeRackId.value === rack.id,
|
|
276
|
+
'mint-rack-editor__tab--dragging': draggingRackId === rack.id,
|
|
277
|
+
'mint-rack-editor__tab--drag-over': dragOverRackId === rack.id,
|
|
278
|
+
},
|
|
294
279
|
]"
|
|
280
|
+
@click="handleTabClick(rack.id)"
|
|
281
|
+
@keydown="onRackReorderKeydown($event, rack, rackIndex)"
|
|
282
|
+
@dragstart="onRackDragStart($event, rack, rackIndex)"
|
|
283
|
+
@dragover="onRackDragOver($event, rack, rackIndex)"
|
|
284
|
+
@dragleave="onRackDragLeave"
|
|
285
|
+
@drop="onRackDrop($event, rack, rackIndex)"
|
|
286
|
+
@dragend="onRackDragEnd"
|
|
295
287
|
>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
288
|
+
<span
|
|
289
|
+
class="mint-rack-editor__slot-dot"
|
|
290
|
+
:style="{ backgroundColor: SLOT_COLORS[rack.slot] }"
|
|
291
|
+
/>
|
|
292
|
+
{{ rack.name }}
|
|
293
|
+
<span
|
|
294
|
+
v-if="getWellCount(rack) > 0"
|
|
295
|
+
:class="[
|
|
296
|
+
'mint-rack-editor__count',
|
|
297
|
+
editor.activeRackId.value === rack.id ? 'mint-rack-editor__count--active' : 'mint-rack-editor__count--inactive',
|
|
298
|
+
]"
|
|
299
|
+
>
|
|
300
|
+
{{ getWellCount(rack) }}
|
|
301
|
+
</span>
|
|
302
|
+
</button>
|
|
299
303
|
<button
|
|
300
304
|
v-if="editor.racks.value.length > minRacks && !readonly"
|
|
305
|
+
type="button"
|
|
301
306
|
class="mint-rack-editor__tab-remove"
|
|
307
|
+
:aria-label="`Remove ${rack.name}`"
|
|
302
308
|
@click.stop="handleRemoveRack(rack.id)"
|
|
303
309
|
>
|
|
304
310
|
×
|
|
305
311
|
</button>
|
|
306
|
-
</
|
|
312
|
+
</div>
|
|
307
313
|
</div>
|
|
308
314
|
|
|
309
315
|
<!-- Add rack button -->
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { SelectOption } from '../types'
|
|
4
|
+
import SearchableSelect from './SearchableSelect.vue'
|
|
5
|
+
|
|
6
|
+
const value = ref<string | number>('viability')
|
|
7
|
+
const options: SelectOption[] = [
|
|
8
|
+
{ value: 'viability', label: 'Live cells (%)', description: 'Primary response metric', meta: 'pct' },
|
|
9
|
+
{ value: 'count', label: 'Cell count', description: 'Absolute cell count', meta: 'count' },
|
|
10
|
+
{ value: 'position', label: 'Well position', disabled: true, disabledReason: 'Coordinate columns cannot be analyzed', meta: 'text' },
|
|
11
|
+
]
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<Story title="Base Inputs/SearchableSelect">
|
|
16
|
+
<Variant title="Rich options">
|
|
17
|
+
<div style="padding: 2rem; max-width: 28rem; min-height: 24rem;">
|
|
18
|
+
<SearchableSelect v-model="value" :options="options">
|
|
19
|
+
<template #footer>Disabled options remain visible with their reason.</template>
|
|
20
|
+
</SearchableSelect>
|
|
21
|
+
</div>
|
|
22
|
+
</Variant>
|
|
23
|
+
</Story>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** Searchable single-select listbox with descriptions, disabled reasons, and custom slots. */
|
|
3
|
+
import { computed, nextTick, ref, useId, watch } from 'vue'
|
|
4
|
+
import type { SelectOption, SelectOptionInput } from '../types'
|
|
5
|
+
import { useEventListener } from '../composables/useEventListener'
|
|
6
|
+
import { normalizeOptionInput } from '../utils/options'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
modelValue?: string | number
|
|
10
|
+
options: SelectOptionInput<string | number>[]
|
|
11
|
+
placeholder?: string
|
|
12
|
+
searchPlaceholder?: string
|
|
13
|
+
noResultsText?: string
|
|
14
|
+
size?: 'sm' | 'md' | 'lg'
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
placeholder: 'Select an option',
|
|
20
|
+
searchPlaceholder: 'Search options…',
|
|
21
|
+
noResultsText: 'No matching options',
|
|
22
|
+
size: 'md',
|
|
23
|
+
disabled: false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
'update:modelValue': [value: string | number]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
defineSlots<{
|
|
31
|
+
option?: (props: { option: SelectOption<string | number>; selected: boolean; active: boolean }) => unknown
|
|
32
|
+
footer?: () => unknown
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const id = useId().replace(/:/g, '')
|
|
36
|
+
const listboxId = `mint-searchable-select-${id}`
|
|
37
|
+
const root = ref<HTMLElement | null>(null)
|
|
38
|
+
const trigger = ref<HTMLButtonElement | null>(null)
|
|
39
|
+
const search = ref<HTMLInputElement | null>(null)
|
|
40
|
+
const open = ref(false)
|
|
41
|
+
const query = ref('')
|
|
42
|
+
const activeIndex = ref(-1)
|
|
43
|
+
const normalizedOptions = computed<SelectOption<string | number>[]>(() =>
|
|
44
|
+
props.options.map(normalizeOptionInput),
|
|
45
|
+
)
|
|
46
|
+
const selectedOption = computed(() =>
|
|
47
|
+
normalizedOptions.value.find(option => option.value === props.modelValue),
|
|
48
|
+
)
|
|
49
|
+
const visibleOptions = computed(() => {
|
|
50
|
+
const needle = query.value.trim().toLocaleLowerCase()
|
|
51
|
+
if (!needle) return normalizedOptions.value
|
|
52
|
+
return normalizedOptions.value.filter(option =>
|
|
53
|
+
[option.label, option.description, option.meta, option.disabledReason]
|
|
54
|
+
.some(value => value?.toLocaleLowerCase().includes(needle)),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
const activeDescendant = computed(() =>
|
|
58
|
+
activeIndex.value >= 0 ? `${listboxId}-option-${activeIndex.value}` : undefined,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
function firstEnabledIndex(options = visibleOptions.value): number {
|
|
62
|
+
return options.findIndex(option => !option.disabled)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function openPanel(): Promise<void> {
|
|
66
|
+
if (props.disabled) return
|
|
67
|
+
query.value = ''
|
|
68
|
+
open.value = true
|
|
69
|
+
activeIndex.value = firstEnabledIndex(normalizedOptions.value)
|
|
70
|
+
await nextTick()
|
|
71
|
+
search.value?.focus()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function closePanel(restoreFocus = false): Promise<void> {
|
|
75
|
+
open.value = false
|
|
76
|
+
query.value = ''
|
|
77
|
+
activeIndex.value = -1
|
|
78
|
+
if (restoreFocus) {
|
|
79
|
+
await nextTick()
|
|
80
|
+
trigger.value?.focus()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function moveActive(direction: 1 | -1): void {
|
|
85
|
+
const enabled = visibleOptions.value
|
|
86
|
+
.map((option, index) => ({ option, index }))
|
|
87
|
+
.filter(({ option }) => !option.disabled)
|
|
88
|
+
if (enabled.length === 0) return
|
|
89
|
+
const current = enabled.findIndex(({ index }) => index === activeIndex.value)
|
|
90
|
+
const next = current < 0
|
|
91
|
+
? (direction === 1 ? 0 : enabled.length - 1)
|
|
92
|
+
: (current + direction + enabled.length) % enabled.length
|
|
93
|
+
activeIndex.value = enabled[next]!.index
|
|
94
|
+
void nextTick(() => document.getElementById(activeDescendant.value ?? '')?.scrollIntoView({ block: 'nearest' }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function select(option: SelectOption<string | number>): void {
|
|
98
|
+
if (option.disabled) return
|
|
99
|
+
emit('update:modelValue', option.value)
|
|
100
|
+
void closePanel(true)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleSearchKeydown(event: KeyboardEvent): void {
|
|
104
|
+
if (event.key === 'ArrowDown') moveActive(1)
|
|
105
|
+
else if (event.key === 'ArrowUp') moveActive(-1)
|
|
106
|
+
else if (event.key === 'Home') activeIndex.value = firstEnabledIndex()
|
|
107
|
+
else if (event.key === 'End') {
|
|
108
|
+
for (let index = visibleOptions.value.length - 1; index >= 0; index--) {
|
|
109
|
+
if (!visibleOptions.value[index]?.disabled) {
|
|
110
|
+
activeIndex.value = index
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (event.key === 'Enter') {
|
|
115
|
+
const option = visibleOptions.value[activeIndex.value]
|
|
116
|
+
if (option) select(option)
|
|
117
|
+
} else if (event.key === 'Escape') {
|
|
118
|
+
void closePanel(true)
|
|
119
|
+
} else return
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleTriggerKeydown(event: KeyboardEvent): void {
|
|
124
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
|
125
|
+
event.preventDefault()
|
|
126
|
+
void openPanel().then(() => {
|
|
127
|
+
if (event.key === 'ArrowUp') moveActive(-1)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
watch(visibleOptions, options => { activeIndex.value = firstEnabledIndex(options) })
|
|
132
|
+
watch(() => props.disabled, disabled => { if (disabled) void closePanel() })
|
|
133
|
+
useEventListener(() => document, 'pointerdown', event => {
|
|
134
|
+
if (open.value && !root.value?.contains(event.target as Node)) void closePanel()
|
|
135
|
+
})
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<template>
|
|
139
|
+
<div
|
|
140
|
+
ref="root"
|
|
141
|
+
:class="['mint-searchable-select', `mint-searchable-select--${size}`]"
|
|
142
|
+
@focusout="!root?.contains($event.relatedTarget as Node | null) && closePanel()"
|
|
143
|
+
>
|
|
144
|
+
<button
|
|
145
|
+
ref="trigger"
|
|
146
|
+
type="button"
|
|
147
|
+
class="mint-searchable-select__trigger"
|
|
148
|
+
:disabled="disabled"
|
|
149
|
+
:aria-expanded="open"
|
|
150
|
+
:aria-controls="listboxId"
|
|
151
|
+
aria-haspopup="listbox"
|
|
152
|
+
@click="open ? closePanel() : openPanel()"
|
|
153
|
+
@keydown="handleTriggerKeydown"
|
|
154
|
+
>
|
|
155
|
+
<span :class="['mint-searchable-select__value', { 'mint-searchable-select__value--placeholder': !selectedOption }]">
|
|
156
|
+
{{ selectedOption?.label ?? placeholder }}
|
|
157
|
+
</span>
|
|
158
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
|
|
159
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="m6 8 4 4 4-4" />
|
|
160
|
+
</svg>
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
<div v-if="open" class="mint-searchable-select__panel">
|
|
164
|
+
<div class="mint-searchable-select__search-wrap">
|
|
165
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
|
|
166
|
+
<circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" />
|
|
167
|
+
</svg>
|
|
168
|
+
<input
|
|
169
|
+
ref="search"
|
|
170
|
+
v-model="query"
|
|
171
|
+
role="combobox"
|
|
172
|
+
type="search"
|
|
173
|
+
class="mint-searchable-select__search"
|
|
174
|
+
:placeholder="searchPlaceholder"
|
|
175
|
+
:aria-label="searchPlaceholder"
|
|
176
|
+
:aria-expanded="open"
|
|
177
|
+
:aria-controls="listboxId"
|
|
178
|
+
:aria-activedescendant="activeDescendant"
|
|
179
|
+
aria-autocomplete="list"
|
|
180
|
+
@keydown="handleSearchKeydown"
|
|
181
|
+
>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<ul :id="listboxId" class="mint-searchable-select__list" role="listbox">
|
|
185
|
+
<li
|
|
186
|
+
v-for="(option, index) in visibleOptions"
|
|
187
|
+
:id="`${listboxId}-option-${index}`"
|
|
188
|
+
:key="String(option.value)"
|
|
189
|
+
role="option"
|
|
190
|
+
class="mint-searchable-select__option"
|
|
191
|
+
:class="{
|
|
192
|
+
'mint-searchable-select__option--active': activeIndex === index,
|
|
193
|
+
'mint-searchable-select__option--selected': modelValue === option.value,
|
|
194
|
+
'mint-searchable-select__option--disabled': option.disabled,
|
|
195
|
+
}"
|
|
196
|
+
:aria-selected="modelValue === option.value"
|
|
197
|
+
:aria-disabled="option.disabled || undefined"
|
|
198
|
+
@mousedown.prevent
|
|
199
|
+
@mouseenter="!option.disabled && (activeIndex = index)"
|
|
200
|
+
@click="select(option)"
|
|
201
|
+
>
|
|
202
|
+
<slot name="option" :option="option" :selected="modelValue === option.value" :active="activeIndex === index">
|
|
203
|
+
<span class="mint-searchable-select__option-text">
|
|
204
|
+
<span class="mint-searchable-select__option-label">{{ option.label }}</span>
|
|
205
|
+
<span v-if="option.disabled ? option.disabledReason || option.description : option.description" class="mint-searchable-select__option-description">
|
|
206
|
+
{{ option.disabled ? option.disabledReason || option.description : option.description }}
|
|
207
|
+
</span>
|
|
208
|
+
</span>
|
|
209
|
+
<span v-if="option.meta" class="mint-searchable-select__option-meta">{{ option.meta }}</span>
|
|
210
|
+
<svg v-if="modelValue === option.value" class="mint-searchable-select__check" viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
|
|
211
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 10 3 3 7-7" />
|
|
212
|
+
</svg>
|
|
213
|
+
</slot>
|
|
214
|
+
</li>
|
|
215
|
+
<li v-if="visibleOptions.length === 0" class="mint-searchable-select__empty">{{ noResultsText }}</li>
|
|
216
|
+
</ul>
|
|
217
|
+
|
|
218
|
+
<div v-if="$slots.footer" class="mint-searchable-select__footer">
|
|
219
|
+
<slot name="footer" />
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</template>
|
|
@@ -211,6 +211,22 @@ function initState() {
|
|
|
211
211
|
</div>
|
|
212
212
|
</Variant>
|
|
213
213
|
|
|
214
|
+
<Variant title="Heatmap QC Decorations">
|
|
215
|
+
<div style="padding: 2rem;">
|
|
216
|
+
<WellPlate
|
|
217
|
+
:format="96"
|
|
218
|
+
:wells="heatmapWells"
|
|
219
|
+
:heatmap="{ enabled: true, min: 0, max: 1, colorScale: 'viridis', showLegend: true }"
|
|
220
|
+
:well-class="well => well.id === 'A1'
|
|
221
|
+
? 'mint-well-plate__well--outlier'
|
|
222
|
+
: well.id === 'A2' ? 'mint-well-plate__well--conflict' : ''"
|
|
223
|
+
selection-mode="none"
|
|
224
|
+
show-labels
|
|
225
|
+
size="md"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</Variant>
|
|
229
|
+
|
|
214
230
|
<Variant title="6-Well Plate">
|
|
215
231
|
<div style="padding: 2rem;">
|
|
216
232
|
<WellPlate
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Interactive 96/384-well plate grid with drag-to-select, heatmap overlays, per-well editing, and sample-color mapping. */
|
|
3
3
|
import { ref, computed } from 'vue'
|
|
4
|
-
import type { WellPlateFormat, WellPlateSelectionMode, WellPlateSize, Well, HeatmapConfig, WellShape, WellEditField, WellEditData, WellLegendItem, ColumnCondition, RowCondition, WellSampleDropData, WellSampleDropParser } from '../types'
|
|
4
|
+
import type { WellPlateFormat, WellPlateSelectionMode, WellPlateSize, Well, HeatmapConfig, WellShape, WellEditField, WellEditData, WellLegendItem, ColumnCondition, RowCondition, WellSampleDropData, WellSampleDropParser, WellClassResolver, WellClassValue, WellStyleResolver } from '../types'
|
|
5
5
|
import { useEventListener } from '../composables/useEventListener'
|
|
6
6
|
import WellEditPopupInternal from './internal/WellEditPopupInternal.vue'
|
|
7
7
|
import {
|
|
@@ -73,6 +73,10 @@ interface Props {
|
|
|
73
73
|
* carry data.
|
|
74
74
|
*/
|
|
75
75
|
emptyMessage?: string
|
|
76
|
+
/** Add semantic classes to individual wells without overloading sample data. */
|
|
77
|
+
wellClass?: WellClassResolver
|
|
78
|
+
/** Add or override individual well styles after heatmap/sample styling. */
|
|
79
|
+
wellStyle?: WellStyleResolver
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -213,8 +217,8 @@ const {
|
|
|
213
217
|
emitSampleDrop: (wellId, data, event) => emit('sample-drop', wellId, data, event),
|
|
214
218
|
})
|
|
215
219
|
|
|
216
|
-
function getWellClasses(well: Well):
|
|
217
|
-
|
|
220
|
+
function getWellClasses(well: Well): WellClassValue[] {
|
|
221
|
+
const base = wellClasses(well, {
|
|
218
222
|
wellShape: props.wellShape,
|
|
219
223
|
selectionMode: props.selectionMode,
|
|
220
224
|
selected: isSelected(well.id),
|
|
@@ -225,13 +229,18 @@ function getWellClasses(well: Well): string[] {
|
|
|
225
229
|
dragSource: dragSourceWell.value === well.id,
|
|
226
230
|
dragTarget: dragTargetWell.value === well.id,
|
|
227
231
|
})
|
|
232
|
+
const custom = props.wellClass?.(well)
|
|
233
|
+
return custom ? [base, custom] : [base]
|
|
228
234
|
}
|
|
229
235
|
|
|
230
|
-
function getWellStyle(well: Well): Record<string, string> {
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
function getWellStyle(well: Well): Record<string, string | number | undefined> {
|
|
237
|
+
return {
|
|
238
|
+
...wellStyle(well, {
|
|
239
|
+
heatmap: props.heatmap,
|
|
240
|
+
sampleColors: props.sampleColors,
|
|
241
|
+
}),
|
|
242
|
+
...props.wellStyle?.(well),
|
|
243
|
+
}
|
|
235
244
|
}
|
|
236
245
|
|
|
237
246
|
function getSampleTypeIndicator(well: Well): string | null {
|
package/src/components/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default as BaseButton } from './BaseButton.vue'
|
|
|
3
3
|
export { default as BaseInput } from './BaseInput.vue'
|
|
4
4
|
export { default as BaseTextarea } from './BaseTextarea.vue'
|
|
5
5
|
export { default as BaseSelect } from './BaseSelect.vue'
|
|
6
|
+
export { default as SearchableSelect } from './SearchableSelect.vue'
|
|
6
7
|
export { default as BaseCheckbox } from './BaseCheckbox.vue'
|
|
7
8
|
export { default as BaseToggle } from './BaseToggle.vue'
|
|
8
9
|
export { default as BaseRadioGroup } from './BaseRadioGroup.vue'
|
|
@@ -55,6 +56,7 @@ export { default as ControlWorkspaceView } from './ControlWorkspaceView.vue'
|
|
|
55
56
|
export { default as DoseDesignWorkspaceView } from './DoseDesignWorkspaceView.vue'
|
|
56
57
|
export { default as AppContainer } from './AppContainer.vue'
|
|
57
58
|
export { default as PluginIcon } from './PluginIcon.vue'
|
|
59
|
+
export { default as LayoutResizeHandle } from './LayoutResizeHandle.vue'
|
|
58
60
|
|
|
59
61
|
// Utility components
|
|
60
62
|
export { default as Skeleton } from './Skeleton.vue'
|
|
@@ -68,6 +70,7 @@ export { default as Breadcrumb } from './Breadcrumb.vue'
|
|
|
68
70
|
export { default as Tooltip } from './Tooltip.vue'
|
|
69
71
|
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
|
70
72
|
export { default as ChartContainer } from './ChartContainer.vue'
|
|
73
|
+
export { default as AdductText } from './AdductText.vue'
|
|
71
74
|
export { default as SettingsModal } from './SettingsModal.vue'
|
|
72
75
|
|
|
73
76
|
// Biological experiment components
|
|
@@ -101,7 +104,6 @@ export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
|
|
|
101
104
|
// Scientific display components
|
|
102
105
|
export { default as PlotlyChart } from './PlotlyChart.vue'
|
|
103
106
|
export { default as ScientificNumber } from './ScientificNumber.vue'
|
|
104
|
-
/** Renders a chemical formula string (H₂O, C₆H₁₂O₆, Ca(OH)₂) with typographically correct subscripts and superscripts for charges. @deprecated Unused by the MINT platform and scheduled for removal in MINT 1.3; no platform replacement is planned. */
|
|
105
107
|
export { default as ChemicalFormula } from './ChemicalFormula.vue'
|
|
106
108
|
export { default as SequenceProgressBar } from './SequenceProgressBar.vue'
|
|
107
109
|
/** Filterable instrument alert/event list keyed by each alert's required stable event_key. @deprecated Unused by the MINT platform and scheduled for removal in MINT 1.3; instrument UI moves to the mld-ms plugins. */
|
|
@@ -20,6 +20,23 @@ const isChoiceField = computed(() =>
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
const componentProps = computed<Record<string, unknown>>(() => {
|
|
23
|
+
if (props.field.type === 'multiselect') {
|
|
24
|
+
const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
|
|
25
|
+
return { ...props.resolvedProps, modelValue: values }
|
|
26
|
+
}
|
|
27
|
+
if (props.field.type === 'tags') {
|
|
28
|
+
const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
|
|
29
|
+
const onUpdate = props.resolvedProps['onUpdate:modelValue'] as ((value: unknown) => void) | undefined
|
|
30
|
+
// TagsInput edits strings; numeric fields are parsed back on the way out so
|
|
31
|
+
// nullable or numeric form data is displayed without being replaced.
|
|
32
|
+
return {
|
|
33
|
+
...props.resolvedProps,
|
|
34
|
+
modelValue: values.map(String),
|
|
35
|
+
'onUpdate:modelValue': (next: unknown) => onUpdate?.(
|
|
36
|
+
props.field.itemType === 'number' && Array.isArray(next) ? next.map(Number) : next,
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
23
40
|
if (!isChoiceField.value) return props.resolvedProps
|
|
24
41
|
|
|
25
42
|
return {
|
|
@@ -34,11 +34,10 @@ defineSlots<{
|
|
|
34
34
|
:title="sidebar.title"
|
|
35
35
|
:subtitle="sidebar.subtitle"
|
|
36
36
|
:badge="sidebar.badge"
|
|
37
|
-
:variant="sidebar.variant"
|
|
38
37
|
:panels="sidebar.panels"
|
|
39
38
|
:active-view="sidebar.activeView"
|
|
40
39
|
:floating="sidebar.floating"
|
|
41
|
-
:
|
|
40
|
+
:density="sidebar.dense ? 'compact' : 'comfortable'"
|
|
42
41
|
:width="sidebar.width"
|
|
43
42
|
:side="sidebar.side"
|
|
44
43
|
:forms="sidebar.forms"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PickerAdapter,
|
|
1
|
+
import type { PickerAdapter, PickerNode } from '../../types/filePicker'
|
|
2
2
|
import { pickerFormat } from './validation'
|
|
3
3
|
|
|
4
4
|
/** Construct metadata only: no FileReader, arrayBuffer or upload. */
|
|
@@ -14,7 +14,10 @@ export function localFilesAdapter(
|
|
|
14
14
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
15
15
|
const path = parts.slice(0, i + 1).join('/')
|
|
16
16
|
if (!nodes.has(path))
|
|
17
|
-
nodes.set(path, {
|
|
17
|
+
nodes.set(path, {
|
|
18
|
+
kind: 'folder', name: parts[i]!, path, parentPath: parent,
|
|
19
|
+
fileCount: 0, bytes: 0, formats: {},
|
|
20
|
+
})
|
|
18
21
|
parent = path
|
|
19
22
|
}
|
|
20
23
|
// Plain file picks can have equal names; preserve both File objects.
|
|
@@ -30,20 +33,24 @@ export function localFilesAdapter(
|
|
|
30
33
|
file,
|
|
31
34
|
})
|
|
32
35
|
}
|
|
36
|
+
const children = new Map<string, PickerNode[]>()
|
|
33
37
|
for (const node of nodes.values()) {
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
node.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
const parent = node.parentPath ?? ''
|
|
39
|
+
const siblings = children.get(parent)
|
|
40
|
+
if (siblings) siblings.push(node)
|
|
41
|
+
else children.set(parent, [node])
|
|
42
|
+
if (node.kind !== 'file') continue
|
|
43
|
+
// Each file contributes once to its ancestors, rather than scanning all
|
|
44
|
+
// files again for every folder in the selection.
|
|
45
|
+
for (let slash = node.path.indexOf('/'); slash >= 0; slash = node.path.indexOf('/', slash + 1)) {
|
|
46
|
+
const folder = nodes.get(node.path.slice(0, slash))
|
|
47
|
+
if (folder?.kind !== 'folder') continue
|
|
48
|
+
folder.fileCount = (folder.fileCount ?? 0) + 1
|
|
49
|
+
folder.bytes = (folder.bytes ?? 0) + (node.bytes ?? 0)
|
|
50
|
+
folder.formats![node.format] = (folder.formats![node.format] ?? 0) + 1
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
|
-
const list = (path: string) =>
|
|
46
|
-
[...nodes.values()].filter((node) => (node.parentPath ?? '') === path)
|
|
53
|
+
const list = (path: string) => [...(children.get(path) ?? [])]
|
|
47
54
|
return {
|
|
48
55
|
listRoot: async () => list(''),
|
|
49
56
|
listChildren: async (path) => list(path),
|
|
@@ -231,12 +231,23 @@ export function usePickerSelection(
|
|
|
231
231
|
)) void resolve()
|
|
232
232
|
},
|
|
233
233
|
)
|
|
234
|
+
const selectedFileCounts = computed(() => {
|
|
235
|
+
const counts = new Map<string, number>()
|
|
236
|
+
for (const file of chosen.value.files) {
|
|
237
|
+
const seen = new Set<string>()
|
|
238
|
+
let parent = file.parentPath
|
|
239
|
+
while (parent && !seen.has(parent)) {
|
|
240
|
+
seen.add(parent)
|
|
241
|
+
counts.set(parent, (counts.get(parent) ?? 0) + 1)
|
|
242
|
+
parent = nav.nodes.value.get(parent)?.parentPath
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return counts
|
|
246
|
+
})
|
|
234
247
|
function folderProgress(node: PickerNode): string {
|
|
235
248
|
if (node.kind !== 'folder' || node.fileCount == null) return ''
|
|
236
|
-
const allowed =
|
|
237
|
-
|
|
238
|
-
).length
|
|
239
|
-
return `${selected(node) ? allowed : 0} / ${node.fileCount}`
|
|
249
|
+
const allowed = selected(node) ? (selectedFileCounts.value.get(node.path) ?? 0) : 0
|
|
250
|
+
return `${allowed} / ${node.fileCount}`
|
|
240
251
|
}
|
|
241
252
|
return {
|
|
242
253
|
keys,
|
package/src/composables/index.ts
CHANGED
|
@@ -139,6 +139,16 @@ export {
|
|
|
139
139
|
type UseDebouncedWatchOptions,
|
|
140
140
|
type UseDebouncedWatchReturn,
|
|
141
141
|
} from './useDebouncedWatch'
|
|
142
|
+
export {
|
|
143
|
+
resizedLeadingPanelWidth,
|
|
144
|
+
resizedTrailingPanelWidth,
|
|
145
|
+
resizedVerticalSplitPercent,
|
|
146
|
+
useManualLayoutResize,
|
|
147
|
+
type ManualLayoutResizeContext,
|
|
148
|
+
type ManualLayoutResizeTarget,
|
|
149
|
+
type ResizePanelWidthConstraints,
|
|
150
|
+
type ResizePercentConstraints,
|
|
151
|
+
} from './useManualLayoutResize'
|
|
142
152
|
export { useFormBuilder, evaluateCondition } from './useFormBuilder'
|
|
143
153
|
export {
|
|
144
154
|
createGeneratedAnalysisTransport,
|
|
@@ -16,7 +16,6 @@ import type {
|
|
|
16
16
|
ControlSchema,
|
|
17
17
|
ControlWorkspaceOptions,
|
|
18
18
|
} from './useControlSchema'
|
|
19
|
-
import type { PluginWorkspaceSidebarVariant } from '../components/PluginWorkspaceView.props'
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Scoped-slot payload shared by PluginWorkspaceView's `default`, `topbar`, and `sidebar`
|
|
@@ -125,8 +124,6 @@ export interface PluginWorkspaceSidebarModel {
|
|
|
125
124
|
subtitle?: string
|
|
126
125
|
/** Optional compact badge/count rendered in the sidebar header. */
|
|
127
126
|
badge?: string | number
|
|
128
|
-
/** AppSidebar visual preset. */
|
|
129
|
-
variant: PluginWorkspaceSidebarVariant
|
|
130
127
|
/** Map of view IDs to sidebar tool sections. */
|
|
131
128
|
panels: Record<string, SidebarToolSection[]>
|
|
132
129
|
/** Resolved active view, used to pick which panels/forms render. */
|