@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.
Files changed (152) hide show
  1. package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
  2. package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  3. package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DELMkUFR.js} +39 -16
  4. package/dist/SettingsModal-DELMkUFR.js.map +1 -0
  5. package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
  6. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  7. package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
  8. package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
  9. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  10. package/dist/__tests__/components/LayoutResizeHandle.test.d.ts +1 -0
  11. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  12. package/dist/__tests__/components/SearchableSelect.test.d.ts +1 -0
  13. package/dist/__tests__/components/WellPlate.decoration.test.d.ts +1 -0
  14. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  15. package/dist/__tests__/composables/useManualLayoutResize.test.d.ts +1 -0
  16. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  17. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  18. package/dist/components/AdductText.vue.d.ts +17 -0
  19. package/dist/components/AppSidebar.vue.d.ts +7 -7
  20. package/dist/components/BasePill.vue.d.ts +3 -0
  21. package/dist/components/BaseTabs.vue.d.ts +1 -4
  22. package/dist/components/ChartContainer.vue.d.ts +19 -5
  23. package/dist/components/ChemicalFormula.vue.d.ts +28 -2
  24. package/dist/components/DataFrame.vue.d.ts +1 -1
  25. package/dist/components/FileUploader.vue.d.ts +3 -0
  26. package/dist/components/FormField.vue.d.ts +8 -1
  27. package/dist/components/LayoutResizeHandle.vue.d.ts +23 -0
  28. package/dist/components/PlotlyChart.vue.d.ts +34 -6
  29. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  30. package/dist/components/SearchableSelect.vue.d.ts +57 -0
  31. package/dist/components/WellPlate.vue.d.ts +5 -1
  32. package/dist/components/index.d.ts +3 -1
  33. package/dist/components/index.js +6 -6
  34. package/dist/{components-DIum5hkx.js → components-CxQVQCpP.js} +1215 -546
  35. package/dist/components-CxQVQCpP.js.map +1 -0
  36. package/dist/composables/index.d.ts +1 -0
  37. package/dist/composables/index.js +8 -8
  38. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  39. package/dist/composables/useManualLayoutResize.d.ts +56 -0
  40. package/dist/composables/useToast.d.ts +22 -1
  41. package/dist/{composables-mFXsTA4g.js → composables-DAqOU8Aq.js} +97 -8
  42. package/dist/composables-DAqOU8Aq.js.map +1 -0
  43. package/dist/index.js +11 -11
  44. package/dist/install.js +2 -2
  45. package/dist/styles.css +855 -320
  46. package/dist/templates/index.js +2 -2
  47. package/dist/{templates-CmGfmBRA.js → templates-NuaNHohp.js} +2 -2
  48. package/dist/{templates-CmGfmBRA.js.map → templates-NuaNHohp.js.map} +1 -1
  49. package/dist/types/componentLabTypes.d.ts +3 -0
  50. package/dist/types/components.d.ts +26 -1
  51. package/dist/types/form-builder.d.ts +2 -0
  52. package/dist/types/generated-ui.d.ts +2 -0
  53. package/dist/types/index.d.ts +1 -1
  54. package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-vLiRPUmj.js} +181 -71
  55. package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
  56. package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
  57. package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  58. package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-D37ajgS8.js} +2 -2
  59. package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
  60. package/dist/{validation-uwoJGEb6.js → validation-ChMyixAf.js} +21 -9
  61. package/dist/validation-ChMyixAf.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  64. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  65. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  66. package/src/__tests__/components/ChartContainer.test.ts +24 -0
  67. package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
  68. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  69. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  70. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  71. package/src/__tests__/components/FormField.test.ts +26 -0
  72. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  73. package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
  74. package/src/__tests__/components/NumberInput.test.ts +149 -0
  75. package/src/__tests__/components/PlotlyChart.test.ts +48 -0
  76. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  77. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  78. package/src/__tests__/components/RackEditor.test.ts +9 -0
  79. package/src/__tests__/components/SearchableSelect.test.ts +61 -0
  80. package/src/__tests__/components/WellPlate.decoration.test.ts +28 -0
  81. package/src/__tests__/composables/localFiles.test.ts +57 -0
  82. package/src/__tests__/composables/useManualLayoutResize.test.ts +21 -0
  83. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  84. package/src/__tests__/generatedUi.test.ts +111 -0
  85. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  86. package/src/components/AdductText.story.vue +15 -0
  87. package/src/components/AdductText.vue +40 -0
  88. package/src/components/AppLayout.story.vue +1 -1
  89. package/src/components/AppSidebar.story.vue +7 -10
  90. package/src/components/AppSidebar.vue +178 -58
  91. package/src/components/AppToastContainer.vue +65 -23
  92. package/src/components/BasePill.story.vue +2 -0
  93. package/src/components/BasePill.vue +4 -0
  94. package/src/components/BaseTabs.story.vue +0 -42
  95. package/src/components/BaseTabs.vue +3 -6
  96. package/src/components/ChartContainer.story.vue +21 -0
  97. package/src/components/ChartContainer.vue +27 -6
  98. package/src/components/ChemicalFormula.story.vue +7 -0
  99. package/src/components/ChemicalFormula.vue +40 -11
  100. package/src/components/FileUploader.vue +10 -1
  101. package/src/components/FormField.vue +10 -3
  102. package/src/components/GeneratedPathInput.vue +11 -7
  103. package/src/components/LayoutResizeHandle.story.vue +26 -0
  104. package/src/components/LayoutResizeHandle.vue +62 -0
  105. package/src/components/NumberInput.story.vue +32 -13
  106. package/src/components/NumberInput.vue +145 -56
  107. package/src/components/PlotlyChart.vue +77 -11
  108. package/src/components/PluginWorkspaceView.props.ts +3 -3
  109. package/src/components/RackEditor.vue +38 -32
  110. package/src/components/SearchableSelect.story.vue +24 -0
  111. package/src/components/SearchableSelect.vue +223 -0
  112. package/src/components/WellPlate.story.vue +16 -0
  113. package/src/components/WellPlate.vue +17 -8
  114. package/src/components/index.ts +3 -1
  115. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  116. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  117. package/src/composables/filePicker/localFiles.ts +21 -14
  118. package/src/composables/filePicker/usePickerSelection.ts +15 -4
  119. package/src/composables/index.ts +10 -0
  120. package/src/composables/pluginWorkspaceModel.ts +0 -3
  121. package/src/composables/useManualLayoutResize.ts +165 -0
  122. package/src/composables/usePluginWorkspace.ts +1 -2
  123. package/src/composables/useToast.ts +21 -5
  124. package/src/generated/schema.ts +58 -18
  125. package/src/styles/components/adduct-text.css +45 -0
  126. package/src/styles/components/app-sidebar.css +282 -121
  127. package/src/styles/components/chart-container.css +92 -0
  128. package/src/styles/components/chemical-formula.css +34 -4
  129. package/src/styles/components/form-builder.css +1 -1
  130. package/src/styles/components/form-field.css +28 -0
  131. package/src/styles/components/layout-resize-handle.css +60 -0
  132. package/src/styles/components/number-input.css +75 -91
  133. package/src/styles/components/pill.css +49 -51
  134. package/src/styles/components/rack-editor.css +31 -9
  135. package/src/styles/components/searchable-select.css +63 -0
  136. package/src/styles/components/segmented-control.css +16 -11
  137. package/src/styles/components/tabs.css +18 -34
  138. package/src/styles/components/toast.css +136 -27
  139. package/src/styles/components/well-plate.css +9 -0
  140. package/src/styles/index.css +3 -0
  141. package/src/styles/variables.css +25 -5
  142. package/src/types/componentLabTypes.ts +6 -0
  143. package/src/types/components.ts +30 -0
  144. package/src/types/form-builder.ts +2 -0
  145. package/src/types/generated-ui.ts +2 -0
  146. package/src/types/index.ts +5 -0
  147. package/dist/SettingsModal-BYR20I4c.js.map +0 -1
  148. package/dist/Skeleton-BAF3CKY7.js.map +0 -1
  149. package/dist/components-DIum5hkx.js.map +0 -1
  150. package/dist/composables-mFXsTA4g.js.map +0 -1
  151. package/dist/useControlSchema-DldpxNOP.js.map +0 -1
  152. 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
- <button
262
+ <div
263
263
  v-for="(rack, rackIndex) in editor.racks.value"
264
264
  :key="rack.id"
265
- :ref="(el) => setTabRef(rack.id, el)"
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
- <span
285
- class="mint-rack-editor__slot-dot"
286
- :style="{ backgroundColor: SLOT_COLORS[rack.slot] }"
287
- />
288
- {{ rack.name }}
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-editor__count',
293
- editor.activeRackId.value === rack.id ? 'mint-rack-editor__count--active' : 'mint-rack-editor__count--inactive',
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
- {{ getWellCount(rack) }}
297
- </span>
298
- <!-- Remove button -->
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
  &times;
305
311
  </button>
306
- </button>
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): string[] {
217
- return wellClasses(well, {
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 wellStyle(well, {
232
- heatmap: props.heatmap,
233
- sampleColors: props.sampleColors,
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 {
@@ -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
- :dense="sidebar.dense"
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, PickerFile, PickerNode } from '../../types/filePicker'
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, { kind: 'folder', name: parts[i]!, path, parentPath: parent })
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
- if (node.kind !== 'folder') continue
35
- const descendants = [...nodes.values()].filter(
36
- (item): item is PickerFile => item.kind === 'file' && item.path.startsWith(`${node.path}/`),
37
- )
38
- node.fileCount = descendants.length
39
- node.bytes = descendants.reduce((sum, file) => sum + (file.bytes ?? 0), 0)
40
- node.formats = {}
41
- descendants.forEach((file) => {
42
- node.formats![file.format] = (node.formats![file.format] ?? 0) + 1
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 = chosen.value.files.filter((file) =>
237
- ancestorSelected(file, new Set([node.path])),
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,
@@ -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. */