@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.
Files changed (84) hide show
  1. package/dist/{SettingsModal-DOcCf0Vo.js → SettingsModal-DELMkUFR.js} +3 -3
  2. package/dist/{SettingsModal-DOcCf0Vo.js.map → SettingsModal-DELMkUFR.js.map} +1 -1
  3. package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
  4. package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
  5. package/dist/__tests__/components/LayoutResizeHandle.test.d.ts +1 -0
  6. package/dist/__tests__/components/SearchableSelect.test.d.ts +1 -0
  7. package/dist/__tests__/components/WellPlate.decoration.test.d.ts +1 -0
  8. package/dist/__tests__/composables/useManualLayoutResize.test.d.ts +1 -0
  9. package/dist/components/AdductText.vue.d.ts +17 -0
  10. package/dist/components/ChartContainer.vue.d.ts +19 -5
  11. package/dist/components/ChemicalFormula.vue.d.ts +28 -2
  12. package/dist/components/DataFrame.vue.d.ts +1 -1
  13. package/dist/components/LayoutResizeHandle.vue.d.ts +23 -0
  14. package/dist/components/PlotlyChart.vue.d.ts +34 -6
  15. package/dist/components/SearchableSelect.vue.d.ts +57 -0
  16. package/dist/components/WellPlate.vue.d.ts +5 -1
  17. package/dist/components/index.d.ts +3 -1
  18. package/dist/components/index.js +4 -4
  19. package/dist/{components-Cp8DcNrU.js → components-CxQVQCpP.js} +773 -317
  20. package/dist/components-CxQVQCpP.js.map +1 -0
  21. package/dist/composables/index.d.ts +1 -0
  22. package/dist/composables/index.js +6 -6
  23. package/dist/composables/useManualLayoutResize.d.ts +56 -0
  24. package/dist/{composables-D_4qqOlp.js → composables-DAqOU8Aq.js} +96 -7
  25. package/dist/composables-DAqOU8Aq.js.map +1 -0
  26. package/dist/index.js +8 -8
  27. package/dist/install.js +2 -2
  28. package/dist/styles.css +294 -16
  29. package/dist/templates/index.js +2 -2
  30. package/dist/{templates-Dd9bBSs6.js → templates-NuaNHohp.js} +2 -2
  31. package/dist/{templates-Dd9bBSs6.js.map → templates-NuaNHohp.js.map} +1 -1
  32. package/dist/types/componentLabTypes.d.ts +3 -0
  33. package/dist/types/components.d.ts +5 -1
  34. package/dist/types/index.d.ts +1 -1
  35. package/dist/{useControlSchema-BB2GRPhW.js → useControlSchema-vLiRPUmj.js} +37 -7
  36. package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
  37. package/dist/{useFormBuilder-BnGSdSRG.js → useFormBuilder-D37ajgS8.js} +2 -2
  38. package/dist/{useFormBuilder-BnGSdSRG.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
  39. package/dist/{validation-CTxuzLIX.js → validation-ChMyixAf.js} +3 -3
  40. package/dist/{validation-CTxuzLIX.js.map → validation-ChMyixAf.js.map} +1 -1
  41. package/package.json +1 -1
  42. package/src/__tests__/components/ChartContainer.test.ts +24 -0
  43. package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
  44. package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
  45. package/src/__tests__/components/NumberInput.test.ts +45 -1
  46. package/src/__tests__/components/PlotlyChart.test.ts +48 -0
  47. package/src/__tests__/components/RackEditor.test.ts +9 -0
  48. package/src/__tests__/components/SearchableSelect.test.ts +61 -0
  49. package/src/__tests__/components/WellPlate.decoration.test.ts +28 -0
  50. package/src/__tests__/composables/useManualLayoutResize.test.ts +21 -0
  51. package/src/components/AdductText.story.vue +15 -0
  52. package/src/components/AdductText.vue +40 -0
  53. package/src/components/ChartContainer.story.vue +21 -0
  54. package/src/components/ChartContainer.vue +27 -6
  55. package/src/components/ChemicalFormula.story.vue +7 -0
  56. package/src/components/ChemicalFormula.vue +40 -11
  57. package/src/components/LayoutResizeHandle.story.vue +26 -0
  58. package/src/components/LayoutResizeHandle.vue +62 -0
  59. package/src/components/NumberInput.story.vue +1 -1
  60. package/src/components/NumberInput.vue +37 -9
  61. package/src/components/PlotlyChart.vue +77 -11
  62. package/src/components/RackEditor.vue +38 -32
  63. package/src/components/SearchableSelect.story.vue +24 -0
  64. package/src/components/SearchableSelect.vue +223 -0
  65. package/src/components/WellPlate.story.vue +16 -0
  66. package/src/components/WellPlate.vue +17 -8
  67. package/src/components/index.ts +3 -1
  68. package/src/composables/index.ts +10 -0
  69. package/src/composables/useManualLayoutResize.ts +165 -0
  70. package/src/styles/components/adduct-text.css +45 -0
  71. package/src/styles/components/chart-container.css +92 -0
  72. package/src/styles/components/chemical-formula.css +34 -4
  73. package/src/styles/components/layout-resize-handle.css +60 -0
  74. package/src/styles/components/number-input.css +2 -0
  75. package/src/styles/components/rack-editor.css +31 -9
  76. package/src/styles/components/searchable-select.css +63 -0
  77. package/src/styles/components/well-plate.css +9 -0
  78. package/src/styles/index.css +3 -0
  79. package/src/types/componentLabTypes.ts +6 -0
  80. package/src/types/components.ts +7 -0
  81. package/src/types/index.ts +3 -0
  82. package/dist/components-Cp8DcNrU.js.map +0 -1
  83. package/dist/composables-D_4qqOlp.js.map +0 -1
  84. 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 anywhere in the field (outside the text) to scrub. Home/End jump to min/max.
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
- // Drag-to-scrub across the field. Pointerdown on the <input> itself is left
119
- // alone so click-to-place-caret and double-click-to-select keep working.
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
- if (props.disabled || !isSliderMode.value) return
129
- if ((event.target as HTMLElement).tagName === 'INPUT') return
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
- event.preventDefault()
132
- area.setPointerCapture?.(event.pointerId)
133
- scrubTo(event.clientX, area)
134
- const move = (e: PointerEvent) => scrubTo(e.clientX, area)
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
- <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