@morscherlab/mint-sdk 1.2.1 → 1.2.3
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/{SettingsModal-DOcCf0Vo.js → SettingsModal-DELMkUFR.js} +3 -3
- package/dist/{SettingsModal-DOcCf0Vo.js.map → SettingsModal-DELMkUFR.js.map} +1 -1
- package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
- package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
- package/dist/__tests__/components/LayoutResizeHandle.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/useManualLayoutResize.test.d.ts +1 -0
- package/dist/components/AdductText.vue.d.ts +17 -0
- 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/LayoutResizeHandle.vue.d.ts +23 -0
- package/dist/components/PlotlyChart.vue.d.ts +34 -6
- 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 +4 -4
- package/dist/{components-Cp8DcNrU.js → components-CxQVQCpP.js} +773 -317
- package/dist/components-CxQVQCpP.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +6 -6
- package/dist/composables/useManualLayoutResize.d.ts +56 -0
- package/dist/{composables-D_4qqOlp.js → composables-DAqOU8Aq.js} +96 -7
- package/dist/composables-DAqOU8Aq.js.map +1 -0
- package/dist/index.js +8 -8
- package/dist/install.js +2 -2
- package/dist/styles.css +294 -16
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Dd9bBSs6.js → templates-NuaNHohp.js} +2 -2
- package/dist/{templates-Dd9bBSs6.js.map → templates-NuaNHohp.js.map} +1 -1
- package/dist/types/componentLabTypes.d.ts +3 -0
- package/dist/types/components.d.ts +5 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/{useControlSchema-BB2GRPhW.js → useControlSchema-vLiRPUmj.js} +37 -7
- package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
- package/dist/{useFormBuilder-BnGSdSRG.js → useFormBuilder-D37ajgS8.js} +2 -2
- package/dist/{useFormBuilder-BnGSdSRG.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
- package/dist/{validation-CTxuzLIX.js → validation-ChMyixAf.js} +3 -3
- package/dist/{validation-CTxuzLIX.js.map → validation-ChMyixAf.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/ChartContainer.test.ts +24 -0
- package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
- package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
- package/src/__tests__/components/NumberInput.test.ts +45 -1
- package/src/__tests__/components/PlotlyChart.test.ts +48 -0
- 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/useManualLayoutResize.test.ts +21 -0
- package/src/components/AdductText.story.vue +15 -0
- package/src/components/AdductText.vue +40 -0
- 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/LayoutResizeHandle.story.vue +26 -0
- package/src/components/LayoutResizeHandle.vue +62 -0
- package/src/components/NumberInput.story.vue +1 -1
- package/src/components/NumberInput.vue +37 -9
- package/src/components/PlotlyChart.vue +77 -11
- 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/composables/index.ts +10 -0
- package/src/composables/useManualLayoutResize.ts +165 -0
- package/src/styles/components/adduct-text.css +45 -0
- package/src/styles/components/chart-container.css +92 -0
- package/src/styles/components/chemical-formula.css +34 -4
- package/src/styles/components/layout-resize-handle.css +60 -0
- package/src/styles/components/number-input.css +2 -0
- package/src/styles/components/rack-editor.css +31 -9
- package/src/styles/components/searchable-select.css +63 -0
- package/src/styles/components/well-plate.css +9 -0
- package/src/styles/index.css +3 -0
- package/src/types/componentLabTypes.ts +6 -0
- package/src/types/components.ts +7 -0
- package/src/types/index.ts +3 -0
- package/dist/components-Cp8DcNrU.js.map +0 -1
- package/dist/composables-D_4qqOlp.js.map +0 -1
- package/dist/useControlSchema-BB2GRPhW.js.map +0 -1
|
@@ -133,7 +133,7 @@ const percent = ref<number | undefined>(45)
|
|
|
133
133
|
<NumberInput v-model="sliderPh" :min="0" :max="14" :step="0.1" unit="pH" />
|
|
134
134
|
<NumberInput v-model="percent" :min="0" :max="100" :step="1" unit="%" />
|
|
135
135
|
<p style="margin: 0; font-size: 0.8125rem; color: var(--text-muted);">
|
|
136
|
-
Drag
|
|
136
|
+
Drag the number left or right to adjust; click to type. Home/End on the slider jump to min/max.
|
|
137
137
|
</p>
|
|
138
138
|
</div>
|
|
139
139
|
</Variant>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Numeric input with always-visible steppers, optional unit label, and (with min+max) an in-field drag-to-scrub range fill. */
|
|
3
|
-
import { computed } from 'vue'
|
|
3
|
+
import { computed, onBeforeUnmount } from 'vue'
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
modelValue?: number
|
|
@@ -115,8 +115,8 @@ function handleKeydown(event: KeyboardEvent, allowJump = false) {
|
|
|
115
115
|
event.preventDefault()
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
118
|
+
// The surrounding range uses absolute position; dragging the number uses a
|
|
119
|
+
// relative offset so grabbing it never jumps away from the current value.
|
|
120
120
|
function scrubTo(clientX: number, area: HTMLElement) {
|
|
121
121
|
const rect = area.getBoundingClientRect()
|
|
122
122
|
if (rect.width === 0 || props.min === undefined || props.max === undefined) return
|
|
@@ -124,22 +124,50 @@ function scrubTo(clientX: number, area: HTMLElement) {
|
|
|
124
124
|
emit('update:modelValue', snap(props.min + ratio * (props.max - props.min)))
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
let stopScrubbing: (() => void) | undefined
|
|
128
|
+
onBeforeUnmount(() => stopScrubbing?.())
|
|
129
|
+
|
|
127
130
|
function handleScrubStart(event: PointerEvent) {
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
const target = event.target as HTMLElement
|
|
132
|
+
const onNumber = target.tagName === 'INPUT'
|
|
133
|
+
if (props.disabled || event.button !== 0 || (!onNumber && !isSliderMode.value)) return
|
|
134
|
+
stopScrubbing?.()
|
|
130
135
|
const area = event.currentTarget as HTMLElement
|
|
131
|
-
|
|
132
|
-
area.
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
const startValue = props.modelValue ?? props.min ?? 0
|
|
137
|
+
const width = area.getBoundingClientRect().width
|
|
138
|
+
const unitsPerPixel = isSliderMode.value && width > 0
|
|
139
|
+
? (props.max! - props.min!) / width
|
|
140
|
+
: props.step / 8
|
|
141
|
+
// Preserve ordinary clicks and double-clicks for caret placement and typing.
|
|
142
|
+
if (!onNumber) {
|
|
143
|
+
event.preventDefault()
|
|
144
|
+
scrubTo(event.clientX, area)
|
|
145
|
+
}
|
|
146
|
+
target.setPointerCapture?.(event.pointerId)
|
|
147
|
+
let dragging = false
|
|
148
|
+
const move = (e: PointerEvent) => {
|
|
149
|
+
if (e.pointerId !== event.pointerId) return
|
|
150
|
+
if (props.disabled) { stop(); return }
|
|
151
|
+
if (!onNumber) { scrubTo(e.clientX, area); return }
|
|
152
|
+
const delta = e.clientX - event.clientX
|
|
153
|
+
if (!dragging && Math.abs(delta) < 4) return
|
|
154
|
+
dragging = true
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
emit('update:modelValue', settle(startValue + Math.round(delta * unitsPerPixel / props.step) * props.step))
|
|
157
|
+
}
|
|
135
158
|
const stop = () => {
|
|
136
159
|
area.removeEventListener('pointermove', move)
|
|
137
160
|
area.removeEventListener('pointerup', stop)
|
|
138
161
|
area.removeEventListener('pointercancel', stop)
|
|
162
|
+
area.removeEventListener('lostpointercapture', stop)
|
|
163
|
+
if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId)
|
|
164
|
+
stopScrubbing = undefined
|
|
139
165
|
}
|
|
166
|
+
stopScrubbing = stop
|
|
140
167
|
area.addEventListener('pointermove', move)
|
|
141
168
|
area.addEventListener('pointerup', stop)
|
|
142
169
|
area.addEventListener('pointercancel', stop)
|
|
170
|
+
area.addEventListener('lostpointercapture', stop)
|
|
143
171
|
}
|
|
144
172
|
</script>
|
|
145
173
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
import type * as PlotlyType from 'plotly.js'
|
|
19
|
-
import { nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
|
19
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
|
20
20
|
|
|
21
21
|
import ChartContainer from './ChartContainer.vue'
|
|
22
22
|
|
|
@@ -30,6 +30,16 @@ interface Props {
|
|
|
30
30
|
empty?: boolean
|
|
31
31
|
emptyMessage?: string
|
|
32
32
|
ariaLabel?: string
|
|
33
|
+
/** Use a custom Plotly build instead of the SDK's lazy-loaded default bundle. */
|
|
34
|
+
plotly?: typeof PlotlyType
|
|
35
|
+
/** Pause rendering and resize work while a tab or panel is inactive. */
|
|
36
|
+
active?: boolean
|
|
37
|
+
/** Plot canvas height. Numbers are interpreted as pixels. */
|
|
38
|
+
height?: string | number
|
|
39
|
+
/** Forward Plotly point clicks through the `plotly-click` event. */
|
|
40
|
+
clickEvents?: boolean
|
|
41
|
+
/** Shared chart container chrome. `frame` fills a workbench panel. */
|
|
42
|
+
variant?: 'card' | 'frame'
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -39,11 +49,28 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
39
49
|
empty: false,
|
|
40
50
|
emptyMessage: 'No data available',
|
|
41
51
|
ariaLabel: 'Interactive Plotly chart',
|
|
52
|
+
active: true,
|
|
53
|
+
clickEvents: false,
|
|
54
|
+
variant: 'card',
|
|
42
55
|
})
|
|
43
56
|
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
'plotly-click': [event: PlotlyType.PlotMouseEvent]
|
|
59
|
+
}>()
|
|
60
|
+
|
|
61
|
+
defineSlots<{
|
|
62
|
+
header?: () => unknown
|
|
63
|
+
toolbar?: () => unknown
|
|
64
|
+
subhead?: () => unknown
|
|
65
|
+
empty?: () => unknown
|
|
66
|
+
error?: () => unknown
|
|
67
|
+
legend?: () => unknown
|
|
68
|
+
footer?: () => unknown
|
|
69
|
+
}>()
|
|
70
|
+
|
|
44
71
|
const chartElement = ref<HTMLDivElement | null>(null)
|
|
45
72
|
const failed = ref(false)
|
|
46
|
-
let plotly: typeof PlotlyType | null = null
|
|
73
|
+
let plotly: typeof PlotlyType | null = props.plotly ?? null
|
|
47
74
|
let resizeObserver: ResizeObserver | null = null
|
|
48
75
|
let themeObserver: MutationObserver | null = null
|
|
49
76
|
let renderedElement: HTMLDivElement | null = null
|
|
@@ -52,10 +79,24 @@ let disposed = false
|
|
|
52
79
|
let revision = 0
|
|
53
80
|
|
|
54
81
|
async function loadPlotly(): Promise<typeof PlotlyType> {
|
|
82
|
+
if (props.plotly) return props.plotly
|
|
55
83
|
const module = await import('plotly.js-dist-min')
|
|
56
84
|
return ('default' in module ? module.default : module) as unknown as typeof PlotlyType
|
|
57
85
|
}
|
|
58
86
|
|
|
87
|
+
type PlotlyEventHost = HTMLDivElement & {
|
|
88
|
+
on?: (eventName: string, callback: (event: PlotlyType.PlotMouseEvent) => void) => void
|
|
89
|
+
removeAllListeners?: (eventName: string) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function bindEvents(element: HTMLDivElement): void {
|
|
93
|
+
const host = element as PlotlyEventHost
|
|
94
|
+
host.removeAllListeners?.('plotly_click')
|
|
95
|
+
if (props.clickEvents) {
|
|
96
|
+
host.on?.('plotly_click', event => emit('plotly-click', event))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
function cssValue(name: string, fallback: string): string {
|
|
60
101
|
const value = getComputedStyle(chartElement.value ?? document.documentElement).getPropertyValue(name).trim()
|
|
61
102
|
return value || getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
|
@@ -98,17 +139,17 @@ function purgeChart(): void {
|
|
|
98
139
|
|
|
99
140
|
async function renderChart(): Promise<void> {
|
|
100
141
|
revision++
|
|
101
|
-
if (rendering || disposed || props.loading || props.empty) return
|
|
142
|
+
if (rendering || disposed || !props.active || props.loading || props.empty) return
|
|
102
143
|
rendering = true
|
|
103
144
|
try {
|
|
104
|
-
while (!disposed && !props.loading && !props.empty) {
|
|
145
|
+
while (!disposed && props.active && !props.loading && !props.empty) {
|
|
105
146
|
const currentRevision = revision
|
|
106
147
|
failed.value = false
|
|
107
148
|
await nextTick()
|
|
108
|
-
if (disposed || props.loading || props.empty) break
|
|
149
|
+
if (disposed || !props.active || props.loading || props.empty) break
|
|
109
150
|
try {
|
|
110
151
|
plotly ??= await loadPlotly()
|
|
111
|
-
if (disposed || props.loading || props.empty) break
|
|
152
|
+
if (disposed || !props.active || props.loading || props.empty) break
|
|
112
153
|
if (currentRevision !== revision) continue
|
|
113
154
|
const element = chartElement.value
|
|
114
155
|
if (!element) break
|
|
@@ -122,7 +163,8 @@ async function renderChart(): Promise<void> {
|
|
|
122
163
|
)
|
|
123
164
|
if (disposed || props.loading || props.empty || element !== chartElement.value) {
|
|
124
165
|
purgeChart()
|
|
125
|
-
} else {
|
|
166
|
+
} else if (props.active) {
|
|
167
|
+
bindEvents(element)
|
|
126
168
|
resizeObserver?.observe(element)
|
|
127
169
|
}
|
|
128
170
|
} catch {
|
|
@@ -140,7 +182,7 @@ async function renderChart(): Promise<void> {
|
|
|
140
182
|
onMounted(() => {
|
|
141
183
|
if (typeof ResizeObserver !== 'undefined') {
|
|
142
184
|
resizeObserver = new ResizeObserver(() => {
|
|
143
|
-
if (rendering || !plotly || !renderedElement) return
|
|
185
|
+
if (rendering || !props.active || !plotly || !renderedElement) return
|
|
144
186
|
// A tab can become hidden or unmount before Plotly's async resize finishes.
|
|
145
187
|
void Promise.resolve(plotly.Plots.resize(renderedElement)).catch(() => {})
|
|
146
188
|
})
|
|
@@ -157,16 +199,29 @@ onMounted(() => {
|
|
|
157
199
|
})
|
|
158
200
|
|
|
159
201
|
watch(
|
|
160
|
-
[() => props.data, () => props.layout, () => props.config, () => props.loading, () => props.empty],
|
|
202
|
+
[() => props.data, () => props.layout, () => props.config, () => props.loading, () => props.empty, () => props.active],
|
|
161
203
|
() => {
|
|
162
|
-
if (props.loading || props.empty) {
|
|
204
|
+
if (props.loading || props.empty || !props.active) {
|
|
163
205
|
resizeObserver?.disconnect()
|
|
164
|
-
if (!rendering) purgeChart()
|
|
206
|
+
if ((props.loading || props.empty) && !rendering) purgeChart()
|
|
165
207
|
}
|
|
166
208
|
void renderChart()
|
|
167
209
|
},
|
|
168
210
|
)
|
|
169
211
|
|
|
212
|
+
watch(
|
|
213
|
+
() => props.clickEvents,
|
|
214
|
+
() => {
|
|
215
|
+
if (renderedElement) bindEvents(renderedElement)
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const chartStyle = computed(() => {
|
|
220
|
+
if (props.height === undefined) return undefined
|
|
221
|
+
const height = typeof props.height === 'number' ? `${props.height}px` : props.height
|
|
222
|
+
return { height, minHeight: height }
|
|
223
|
+
})
|
|
224
|
+
|
|
170
225
|
onBeforeUnmount(() => {
|
|
171
226
|
disposed = true
|
|
172
227
|
resizeObserver?.disconnect()
|
|
@@ -183,10 +238,17 @@ onBeforeUnmount(() => {
|
|
|
183
238
|
:loading="loading"
|
|
184
239
|
:empty="empty"
|
|
185
240
|
:empty-message="emptyMessage"
|
|
241
|
+
:variant="variant"
|
|
186
242
|
>
|
|
243
|
+
<template v-if="$slots.header" #header>
|
|
244
|
+
<slot name="header" />
|
|
245
|
+
</template>
|
|
187
246
|
<template v-if="$slots.toolbar" #toolbar>
|
|
188
247
|
<slot name="toolbar" />
|
|
189
248
|
</template>
|
|
249
|
+
<template v-if="$slots.subhead" #subhead>
|
|
250
|
+
<slot name="subhead" />
|
|
251
|
+
</template>
|
|
190
252
|
<template v-if="$slots.empty" #empty>
|
|
191
253
|
<slot name="empty" />
|
|
192
254
|
</template>
|
|
@@ -204,11 +266,15 @@ onBeforeUnmount(() => {
|
|
|
204
266
|
class="mint-plotly-chart__plot"
|
|
205
267
|
role="group"
|
|
206
268
|
:aria-label="ariaLabel"
|
|
269
|
+
:style="chartStyle"
|
|
207
270
|
/>
|
|
208
271
|
|
|
209
272
|
<template v-if="$slots.legend" #legend>
|
|
210
273
|
<slot name="legend" />
|
|
211
274
|
</template>
|
|
275
|
+
<template v-if="$slots.footer" #footer>
|
|
276
|
+
<slot name="footer" />
|
|
277
|
+
</template>
|
|
212
278
|
</ChartContainer>
|
|
213
279
|
</template>
|
|
214
280
|
|
|
@@ -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
|