@morscherlab/mint-sdk 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import ChartContainer from '../../components/ChartContainer.vue'
|
|
5
|
+
|
|
6
|
+
describe('ChartContainer', () => {
|
|
7
|
+
it('renders workbench header, subhead, and footer slots in a filling frame', () => {
|
|
8
|
+
const wrapper = mount(ChartContainer, {
|
|
9
|
+
props: { variant: 'frame' },
|
|
10
|
+
slots: {
|
|
11
|
+
header: 'Header',
|
|
12
|
+
subhead: 'Subhead',
|
|
13
|
+
default: 'Chart',
|
|
14
|
+
footer: 'Footer',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(wrapper.classes()).toContain('mint-chart--frame')
|
|
19
|
+
expect(wrapper.text()).toContain('Header')
|
|
20
|
+
expect(wrapper.text()).toContain('Subhead')
|
|
21
|
+
expect(wrapper.text()).toContain('Chart')
|
|
22
|
+
expect(wrapper.get('.mint-chart__footer').text()).toBe('Footer')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import AdductText from '../../components/AdductText.vue'
|
|
5
|
+
import ChemicalFormula from '../../components/ChemicalFormula.vue'
|
|
6
|
+
|
|
7
|
+
describe('ChemicalFormula', () => {
|
|
8
|
+
it('renders formula typography and an adduct as separate parts', () => {
|
|
9
|
+
const wrapper = mount(ChemicalFormula, {
|
|
10
|
+
props: { formula: 'C6H12O6', adduct: '[M+H]+', pill: true },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
expect(wrapper.findAll('.mint-chem-formula__subscript').map(node => node.text()))
|
|
14
|
+
.toEqual(['6', '12', '6'])
|
|
15
|
+
expect(wrapper.getComponent(AdductText).text()).toBe('[M+H]+')
|
|
16
|
+
expect(wrapper.classes()).toContain('mint-chem-formula--pill')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders explicit empty text when no formula is available', () => {
|
|
20
|
+
expect(mount(ChemicalFormula, { props: { emptyText: 'Unknown' } }).text()).toBe('Unknown')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('supports standalone sans-serif adduct labels', () => {
|
|
24
|
+
expect(mount(AdductText, { props: { value: '[M-H]-', font: 'sans' } }).classes())
|
|
25
|
+
.toContain('mint-adduct-text--sans')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import LayoutResizeHandle from '../../components/LayoutResizeHandle.vue'
|
|
5
|
+
|
|
6
|
+
describe('LayoutResizeHandle', () => {
|
|
7
|
+
it('exposes splitter values and supports arrow, Home, and End keys', async () => {
|
|
8
|
+
const wrapper = mount(LayoutResizeHandle, {
|
|
9
|
+
props: { modelValue: 320, min: 240, max: 480, step: 20, label: 'Resize sidebar', orientation: 'vertical' },
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(wrapper.attributes('role')).toBe('separator')
|
|
13
|
+
expect(wrapper.attributes('aria-valuenow')).toBe('320')
|
|
14
|
+
await wrapper.trigger('keydown', { key: 'ArrowRight' })
|
|
15
|
+
await wrapper.trigger('keydown', { key: 'Home' })
|
|
16
|
+
await wrapper.trigger('keydown', { key: 'End' })
|
|
17
|
+
expect(wrapper.emitted('update:modelValue')?.map(event => event[0])).toEqual([340, 240, 480])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -48,10 +48,54 @@ describe('NumberInput', () => {
|
|
|
48
48
|
expect(lastEmitted(wrapper)).toBe(12.5)
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('should
|
|
51
|
+
it('should leave clicks and small pointer movements on the number available for typing', async () => {
|
|
52
52
|
const wrapper = mount(NumberInput, { props: { modelValue: 0, min: 0, max: 50 } })
|
|
53
53
|
await wrapper.find('input').trigger('pointerdown', { clientX: 150, pointerId: 1 })
|
|
54
|
+
await wrapper.find('input').trigger('pointermove', { clientX: 152, pointerId: 1 })
|
|
55
|
+
await wrapper.find('input').trigger('pointerup', { pointerId: 1 })
|
|
54
56
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
57
|
+
await wrapper.find('input').setValue('12')
|
|
58
|
+
expect(lastEmitted(wrapper)).toBe(12)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should drag the number relative to its starting value and stop on release', async () => {
|
|
62
|
+
const wrapper = mount(NumberInput, { props: { modelValue: 10, min: 0, max: 50, step: 0.5 } })
|
|
63
|
+
const input = wrapper.find('input')
|
|
64
|
+
wrapper.find('.mint-number-input__scrub').element.getBoundingClientRect = () => ({ left: 100, width: 200 } as DOMRect)
|
|
65
|
+
await input.trigger('pointerdown', { clientX: 120, pointerId: 1 })
|
|
66
|
+
await input.trigger('pointermove', { clientX: 170, pointerId: 1 })
|
|
67
|
+
expect(lastEmitted(wrapper)).toBe(22.5)
|
|
68
|
+
await input.trigger('pointermove', { clientX: 500, pointerId: 1 })
|
|
69
|
+
expect(lastEmitted(wrapper)).toBe(50)
|
|
70
|
+
await input.trigger('pointerup', { pointerId: 1 })
|
|
71
|
+
const count = wrapper.emitted('update:modelValue')?.length
|
|
72
|
+
await input.trigger('pointermove', { clientX: 100, pointerId: 1 })
|
|
73
|
+
expect(wrapper.emitted('update:modelValue')).toHaveLength(count!)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should drag numbers without a full range in step increments', async () => {
|
|
77
|
+
const wrapper = mount(NumberInput, { props: { modelValue: 2.5, min: 0, step: 1 } })
|
|
78
|
+
const input = wrapper.find('input')
|
|
79
|
+
await input.trigger('pointerdown', { clientX: 100, pointerId: 1 })
|
|
80
|
+
await input.trigger('pointermove', { clientX: 116, pointerId: 1 })
|
|
81
|
+
expect(lastEmitted(wrapper)).toBe(4.5)
|
|
82
|
+
await input.trigger('pointermove', { clientX: 60, pointerId: 1 })
|
|
83
|
+
expect(lastEmitted(wrapper)).toBe(0)
|
|
84
|
+
await input.trigger('pointercancel', { pointerId: 1 })
|
|
85
|
+
const count = wrapper.emitted('update:modelValue')?.length
|
|
86
|
+
await input.trigger('pointermove', { clientX: 120, pointerId: 1 })
|
|
87
|
+
expect(wrapper.emitted('update:modelValue')).toHaveLength(count!)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should ignore disabled inputs and non-primary buttons when dragging', async () => {
|
|
91
|
+
for (const props of [{ disabled: true }, { disabled: false }]) {
|
|
92
|
+
const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10, ...props } })
|
|
93
|
+
const input = wrapper.find('input')
|
|
94
|
+
await input.trigger('pointerdown', { clientX: 100, pointerId: 1, button: props.disabled ? 0 : 2 })
|
|
95
|
+
await input.trigger('pointermove', { clientX: 150, pointerId: 1 })
|
|
96
|
+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
97
|
+
wrapper.unmount()
|
|
98
|
+
}
|
|
55
99
|
})
|
|
56
100
|
|
|
57
101
|
it('should step with the arrow keys', async () => {
|
|
@@ -302,4 +302,52 @@ describe('PlotlyChart', () => {
|
|
|
302
302
|
|
|
303
303
|
expect(plotly.purge).toHaveBeenCalledTimes(1)
|
|
304
304
|
})
|
|
305
|
+
|
|
306
|
+
it('should use an injected Plotly build and forward point clicks', async () => {
|
|
307
|
+
let clickHandler: ((event: unknown) => void) | undefined
|
|
308
|
+
const custom = {
|
|
309
|
+
Plots: { resize: vi.fn() },
|
|
310
|
+
purge: vi.fn(),
|
|
311
|
+
react: vi.fn(async (element: HTMLElement) => {
|
|
312
|
+
Object.assign(element, {
|
|
313
|
+
on: (_name: string, handler: (event: unknown) => void) => { clickHandler = handler },
|
|
314
|
+
removeAllListeners: vi.fn(),
|
|
315
|
+
})
|
|
316
|
+
}),
|
|
317
|
+
}
|
|
318
|
+
const wrapper = mount(PlotlyChart, {
|
|
319
|
+
props: {
|
|
320
|
+
data: [{ x: [1] }],
|
|
321
|
+
plotly: custom as never,
|
|
322
|
+
clickEvents: true,
|
|
323
|
+
height: 320,
|
|
324
|
+
variant: 'frame',
|
|
325
|
+
},
|
|
326
|
+
slots: { header: 'Chart header', footer: 'Time (min)' },
|
|
327
|
+
})
|
|
328
|
+
await flushPromises()
|
|
329
|
+
|
|
330
|
+
expect(custom.react).toHaveBeenCalledOnce()
|
|
331
|
+
expect(plotly.react).not.toHaveBeenCalled()
|
|
332
|
+
expect(wrapper.get('.mint-plotly-chart__plot').attributes('style')).toContain('height: 320px')
|
|
333
|
+
expect(wrapper.get('.mint-chart').classes()).toContain('mint-chart--frame')
|
|
334
|
+
expect(wrapper.text()).toContain('Chart header')
|
|
335
|
+
expect(wrapper.text()).toContain('Time (min)')
|
|
336
|
+
|
|
337
|
+
const event = { points: [] }
|
|
338
|
+
clickHandler?.(event)
|
|
339
|
+
expect(wrapper.emitted('plotly-click')?.[0]?.[0]).toBe(event)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('should wait to render until an inactive chart becomes active', async () => {
|
|
343
|
+
const wrapper = mount(PlotlyChart, {
|
|
344
|
+
props: { data: [{ x: [1] }], active: false },
|
|
345
|
+
})
|
|
346
|
+
await flushPromises()
|
|
347
|
+
expect(plotly.react).not.toHaveBeenCalled()
|
|
348
|
+
|
|
349
|
+
await wrapper.setProps({ active: true })
|
|
350
|
+
await flushPromises()
|
|
351
|
+
expect(plotly.react).toHaveBeenCalledOnce()
|
|
352
|
+
})
|
|
305
353
|
})
|
|
@@ -40,6 +40,15 @@ function jsonDataTransfer(data: unknown): DataTransfer {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
describe('RackEditor', () => {
|
|
43
|
+
it('keeps rack selection and removal as sibling buttons', () => {
|
|
44
|
+
const racks = makeRacks()
|
|
45
|
+
racks.push({ ...racks[0], id: 'rack-2', name: 'Rack 2', wells: {} })
|
|
46
|
+
const wrapper = mount(RackEditor, { props: { modelValue: racks } })
|
|
47
|
+
|
|
48
|
+
expect(wrapper.find('.mint-rack-editor__tab button').exists()).toBe(false)
|
|
49
|
+
expect(wrapper.get('.mint-rack-editor__tab-item').findAll('button')).toHaveLength(2)
|
|
50
|
+
})
|
|
51
|
+
|
|
43
52
|
it('passes rack-aware well editor slot props through to WellPlate', async () => {
|
|
44
53
|
const wrapper = mount(RackEditor, {
|
|
45
54
|
props: {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import SearchableSelect from '../../components/SearchableSelect.vue'
|
|
5
|
+
|
|
6
|
+
const options = [
|
|
7
|
+
{ value: 'alpha', label: 'Alpha', description: 'First option' },
|
|
8
|
+
{ value: 'beta', label: 'Beta', disabled: true, disabledReason: 'Unavailable' },
|
|
9
|
+
{ value: 'gamma', label: 'Gamma', meta: 'count' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('SearchableSelect', () => {
|
|
13
|
+
it('filters rich options and explains disabled choices', async () => {
|
|
14
|
+
const wrapper = mount(SearchableSelect, { props: { options } })
|
|
15
|
+
await wrapper.get('.mint-searchable-select__trigger').trigger('click')
|
|
16
|
+
await wrapper.get('input').setValue('bet')
|
|
17
|
+
|
|
18
|
+
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
|
|
19
|
+
expect(wrapper.get('[role="option"]').text()).toContain('Unavailable')
|
|
20
|
+
expect(wrapper.get('[role="option"]').attributes('aria-disabled')).toBe('true')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('skips disabled options during keyboard selection', async () => {
|
|
24
|
+
const wrapper = mount(SearchableSelect, { props: { options } })
|
|
25
|
+
await wrapper.get('.mint-searchable-select__trigger').trigger('click')
|
|
26
|
+
await flushPromises()
|
|
27
|
+
await wrapper.get('input').trigger('keydown', { key: 'ArrowDown' })
|
|
28
|
+
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
|
29
|
+
|
|
30
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['gamma'])
|
|
31
|
+
expect(wrapper.get('.mint-searchable-select__trigger').text()).toContain('Select an option')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps footer actions reachable until focus leaves the select', async () => {
|
|
35
|
+
const wrapper = mount(SearchableSelect, {
|
|
36
|
+
attachTo: document.body,
|
|
37
|
+
props: { options },
|
|
38
|
+
slots: { footer: '<button type="button">Add option</button>' },
|
|
39
|
+
})
|
|
40
|
+
const outside = document.createElement('button')
|
|
41
|
+
document.body.append(outside)
|
|
42
|
+
try {
|
|
43
|
+
await wrapper.get('.mint-searchable-select__trigger').trigger('click')
|
|
44
|
+
await wrapper.get('input').trigger('keydown', { key: 'Tab' })
|
|
45
|
+
expect(wrapper.find('.mint-searchable-select__footer').exists()).toBe(true)
|
|
46
|
+
|
|
47
|
+
const footer = wrapper.get<HTMLButtonElement>('.mint-searchable-select__footer button')
|
|
48
|
+
footer.element.focus()
|
|
49
|
+
await flushPromises()
|
|
50
|
+
expect(document.activeElement).toBe(footer.element)
|
|
51
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
|
|
52
|
+
|
|
53
|
+
outside.focus()
|
|
54
|
+
await flushPromises()
|
|
55
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
56
|
+
} finally {
|
|
57
|
+
wrapper.unmount()
|
|
58
|
+
outside.remove()
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import WellPlate from '../../components/WellPlate.vue'
|
|
5
|
+
|
|
6
|
+
describe('WellPlate per-well decoration', () => {
|
|
7
|
+
it('applies custom classes and lets custom styles override heatmap borders', () => {
|
|
8
|
+
const wrapper = mount(WellPlate, {
|
|
9
|
+
props: {
|
|
10
|
+
format: 6,
|
|
11
|
+
wells: {
|
|
12
|
+
A1: { id: 'A1', row: 0, col: 0, state: 'filled', value: 0.5 },
|
|
13
|
+
},
|
|
14
|
+
heatmap: { enabled: true, min: 0, max: 1 },
|
|
15
|
+
wellClass: well => well.id === 'A1' ? 'mint-well-plate__well--outlier' : undefined,
|
|
16
|
+
wellStyle: well => well.id === 'A1'
|
|
17
|
+
? { borderWidth: '2px', borderStyle: 'solid', borderColor: 'var(--mint-warning)' }
|
|
18
|
+
: undefined,
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const well = wrapper.findAll('.mint-well-plate__well')[0]
|
|
23
|
+
expect(well.classes()).toContain('mint-well-plate__well--outlier')
|
|
24
|
+
const style = (well.element as HTMLElement).style
|
|
25
|
+
expect([style.borderWidth, style.borderStyle, style.borderColor])
|
|
26
|
+
.toEqual(['2px', 'solid', 'var(--mint-warning)'])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
resizedLeadingPanelWidth,
|
|
4
|
+
resizedTrailingPanelWidth,
|
|
5
|
+
resizedVerticalSplitPercent,
|
|
6
|
+
} from '../../composables/useManualLayoutResize'
|
|
7
|
+
|
|
8
|
+
describe('manual layout resize helpers', () => {
|
|
9
|
+
const limits = { minWidth: 260, maxWidth: 480, mainMinWidth: 560, layoutGap: 16 }
|
|
10
|
+
|
|
11
|
+
it('clamps leading, trailing, and vertical splits', () => {
|
|
12
|
+
expect(resizedLeadingPanelWidth(320, 400, 900, limits)).toBe(324)
|
|
13
|
+
expect(resizedTrailingPanelWidth(320, -400, 1400, limits)).toBe(480)
|
|
14
|
+
expect(resizedVerticalSplitPercent(60, 400, 800, { minPercent: 35, maxPercent: 75 })).toBe(75)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('preserves the panel minimum when the container cannot fit both panes', () => {
|
|
18
|
+
expect(resizedLeadingPanelWidth(320, 0, 500, limits)).toBe(260)
|
|
19
|
+
expect(resizedTrailingPanelWidth(320, 0, 800, limits)).toBe(260)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import AdductText from './AdductText.vue'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<Story title="Data Display/AdductText">
|
|
7
|
+
<Variant title="Inline and badge">
|
|
8
|
+
<div style="display: flex; align-items: center; gap: 1rem; padding: 2rem; color: var(--text-primary);">
|
|
9
|
+
<AdductText value="[M+H]+" />
|
|
10
|
+
<AdductText value="[M+Na]+" variant="badge" />
|
|
11
|
+
<AdductText />
|
|
12
|
+
</div>
|
|
13
|
+
</Variant>
|
|
14
|
+
</Story>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** Compact, overflow-safe display for mass-spectrometry adduct notation. */
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
value?: string | null
|
|
7
|
+
emptyText?: string
|
|
8
|
+
variant?: 'inline' | 'badge'
|
|
9
|
+
tone?: 'default' | 'on-primary' | 'muted'
|
|
10
|
+
size?: 'xs' | 'inherit'
|
|
11
|
+
font?: 'mono' | 'sans'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
value: null,
|
|
16
|
+
emptyText: '-',
|
|
17
|
+
variant: 'inline',
|
|
18
|
+
tone: 'default',
|
|
19
|
+
size: 'xs',
|
|
20
|
+
font: 'mono',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const value = computed(() => props.value?.trim() ?? '')
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<span
|
|
28
|
+
:class="[
|
|
29
|
+
'mint-adduct-text',
|
|
30
|
+
`mint-adduct-text--${variant}`,
|
|
31
|
+
`mint-adduct-text--${tone}`,
|
|
32
|
+
`mint-adduct-text--${size}`,
|
|
33
|
+
`mint-adduct-text--${font}`,
|
|
34
|
+
{ 'mint-adduct-text--empty': !value },
|
|
35
|
+
]"
|
|
36
|
+
:title="value || undefined"
|
|
37
|
+
>
|
|
38
|
+
{{ value || emptyText }}
|
|
39
|
+
</span>
|
|
40
|
+
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import ChartContainer from './ChartContainer.vue'
|
|
3
|
+
import IconButton from './IconButton.vue'
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<template>
|
|
@@ -109,5 +110,25 @@ import ChartContainer from './ChartContainer.vue'
|
|
|
109
110
|
</ChartContainer>
|
|
110
111
|
</div>
|
|
111
112
|
</Variant>
|
|
113
|
+
|
|
114
|
+
<Variant title="Workbench Frame">
|
|
115
|
+
<div style="height: 420px; border: 1px solid var(--border-color); border-radius: 0.5rem; overflow: hidden;">
|
|
116
|
+
<ChartContainer variant="frame" empty empty-message="Select a sample to populate this chart">
|
|
117
|
+
<template #header>
|
|
118
|
+
<strong style="font-size: 0.8125rem; color: var(--text-primary);">Extracted Ion Chromatogram</strong>
|
|
119
|
+
</template>
|
|
120
|
+
<template #toolbar>
|
|
121
|
+
<IconButton size="sm" label="Reset zoom">
|
|
122
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
|
123
|
+
<path d="M4 4v6h6M20 20v-6h-6M5.5 15a7 7 0 0 0 11.8 2M18.5 9A7 7 0 0 0 6.7 7" />
|
|
124
|
+
</svg>
|
|
125
|
+
</IconButton>
|
|
126
|
+
</template>
|
|
127
|
+
<template #footer>
|
|
128
|
+
Retention time (min)
|
|
129
|
+
</template>
|
|
130
|
+
</ChartContainer>
|
|
131
|
+
</div>
|
|
132
|
+
</Variant>
|
|
112
133
|
</Story>
|
|
113
134
|
</template>
|
|
@@ -9,27 +9,44 @@ interface Props {
|
|
|
9
9
|
loading?: boolean
|
|
10
10
|
empty?: boolean
|
|
11
11
|
emptyMessage?: string
|
|
12
|
+
/** `frame` fills its parent and uses compact workbench chrome. */
|
|
13
|
+
variant?: 'card' | 'frame'
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
withDefaults(defineProps<Props>(), {
|
|
15
17
|
loading: false,
|
|
16
18
|
empty: false,
|
|
17
19
|
emptyMessage: 'No data available',
|
|
20
|
+
variant: 'card',
|
|
18
21
|
})
|
|
22
|
+
|
|
23
|
+
defineSlots<{
|
|
24
|
+
default?: () => unknown
|
|
25
|
+
header?: () => unknown
|
|
26
|
+
toolbar?: () => unknown
|
|
27
|
+
subhead?: () => unknown
|
|
28
|
+
empty?: () => unknown
|
|
29
|
+
legend?: () => unknown
|
|
30
|
+
footer?: () => unknown
|
|
31
|
+
}>()
|
|
19
32
|
</script>
|
|
20
33
|
|
|
21
34
|
<template>
|
|
22
|
-
<div class="mint-chart">
|
|
23
|
-
<div v-if="title || description || $slots.toolbar" class="mint-chart__header">
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
<div :class="['mint-chart', `mint-chart--${variant}`]">
|
|
36
|
+
<div v-if="title || description || $slots.header || $slots.toolbar" class="mint-chart__header">
|
|
37
|
+
<slot name="header">
|
|
38
|
+
<div v-if="title || description" class="mint-chart__header-text">
|
|
39
|
+
<h3 v-if="title" class="mint-chart__title">{{ title }}</h3>
|
|
40
|
+
<p v-if="description" class="mint-chart__description">{{ description }}</p>
|
|
41
|
+
</div>
|
|
42
|
+
</slot>
|
|
28
43
|
<div v-if="$slots.toolbar" class="mint-chart__toolbar">
|
|
29
44
|
<slot name="toolbar" />
|
|
30
45
|
</div>
|
|
31
46
|
</div>
|
|
32
47
|
|
|
48
|
+
<slot name="subhead" />
|
|
49
|
+
|
|
33
50
|
<div class="mint-chart__body">
|
|
34
51
|
<!-- Loading overlay -->
|
|
35
52
|
<div v-if="loading" class="mint-chart__loading-overlay">
|
|
@@ -56,5 +73,9 @@ withDefaults(defineProps<Props>(), {
|
|
|
56
73
|
<div v-if="$slots.legend" class="mint-chart__legend">
|
|
57
74
|
<slot name="legend" />
|
|
58
75
|
</div>
|
|
76
|
+
|
|
77
|
+
<div v-if="$slots.footer" class="mint-chart__footer">
|
|
78
|
+
<slot name="footer" />
|
|
79
|
+
</div>
|
|
59
80
|
</div>
|
|
60
81
|
</template>
|
|
@@ -98,5 +98,12 @@ const formulas = [
|
|
|
98
98
|
</div>
|
|
99
99
|
</div>
|
|
100
100
|
</Variant>
|
|
101
|
+
|
|
102
|
+
<Variant title="Formula with Adduct">
|
|
103
|
+
<div style="display: flex; gap: 1rem; padding: 2rem; align-items: center;">
|
|
104
|
+
<ChemicalFormula formula="C6H12O6" adduct="[M+H]+" size="sm" />
|
|
105
|
+
<ChemicalFormula formula="C3H6O3" adduct="[M-H]-" size="sm" pill tone="secondary" />
|
|
106
|
+
</div>
|
|
107
|
+
</Variant>
|
|
101
108
|
</Story>
|
|
102
109
|
</template>
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
/** Renders a chemical formula
|
|
2
|
+
/** Renders a chemical formula and optional MS adduct with typographically correct subscripts and charges. */
|
|
3
|
+
import { computed } from 'vue'
|
|
3
4
|
import { useChemicalFormula, type FormulaPart } from '../composables/useChemicalFormula'
|
|
5
|
+
import AdductText from './AdductText.vue'
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
|
-
formula
|
|
8
|
+
formula?: string | null
|
|
7
9
|
inline?: boolean
|
|
10
|
+
adduct?: string | null
|
|
11
|
+
emptyText?: string
|
|
12
|
+
pill?: boolean
|
|
13
|
+
size?: 'xs' | 'sm' | 'inherit'
|
|
14
|
+
tone?: 'primary' | 'secondary' | 'muted'
|
|
8
15
|
}
|
|
9
16
|
|
|
10
|
-
withDefaults(defineProps<Props>(), {
|
|
17
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
18
|
+
formula: null,
|
|
11
19
|
inline: true,
|
|
20
|
+
adduct: null,
|
|
21
|
+
emptyText: '-',
|
|
22
|
+
pill: false,
|
|
23
|
+
size: 'inherit',
|
|
24
|
+
tone: 'primary',
|
|
12
25
|
})
|
|
13
26
|
|
|
14
27
|
const { renderFormulaParts } = useChemicalFormula()
|
|
28
|
+
const formula = computed(() => props.formula?.trim() ?? '')
|
|
29
|
+
const title = computed(() => [formula.value, props.adduct?.trim()].filter(Boolean).join(' '))
|
|
15
30
|
|
|
16
31
|
function getParts(f: string): FormulaPart[] {
|
|
17
32
|
return renderFormulaParts(f)
|
|
@@ -21,15 +36,29 @@ function getParts(f: string): FormulaPart[] {
|
|
|
21
36
|
<template>
|
|
22
37
|
<component
|
|
23
38
|
:is="inline ? 'span' : 'div'"
|
|
24
|
-
:class="[
|
|
39
|
+
:class="[
|
|
40
|
+
'mint-chem-formula',
|
|
41
|
+
!inline ? 'mint-chem-formula--block' : '',
|
|
42
|
+
`mint-chem-formula--${size}`,
|
|
43
|
+
`mint-chem-formula--${tone}`,
|
|
44
|
+
{ 'mint-chem-formula--pill': pill, 'mint-chem-formula--empty': !formula },
|
|
45
|
+
]"
|
|
46
|
+
:title="title || undefined"
|
|
25
47
|
>
|
|
26
|
-
<template v-
|
|
27
|
-
<span
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
<template v-if="formula">
|
|
49
|
+
<span class="mint-chem-formula__formula">
|
|
50
|
+
<template v-for="(part, i) in getParts(formula)" :key="i">
|
|
51
|
+
<span v-if="part.type === 'element'" class="mint-chem-formula__element">{{ part.text }}</span>
|
|
52
|
+
<span v-else-if="part.type === 'subscript'" class="mint-chem-formula__subscript">{{ part.text }}</span>
|
|
53
|
+
<span v-else-if="part.type === 'superscript'" class="mint-chem-formula__superscript">{{ part.text }}</span>
|
|
54
|
+
<span v-else-if="part.type === 'paren'" class="mint-chem-formula__paren">{{ part.text }}</span>
|
|
55
|
+
<span v-else-if="part.type === 'dot'" class="mint-chem-formula__dot">{{ part.text }}</span>
|
|
56
|
+
<span v-else-if="part.type === 'charge'" class="mint-chem-formula__charge">{{ part.text }}</span>
|
|
57
|
+
</template>
|
|
58
|
+
</span>
|
|
59
|
+
<AdductText v-if="adduct" :value="adduct" size="inherit" />
|
|
60
|
+
<slot />
|
|
33
61
|
</template>
|
|
62
|
+
<span v-else>{{ emptyText }}</span>
|
|
34
63
|
</component>
|
|
35
64
|
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import LayoutResizeHandle from './LayoutResizeHandle.vue'
|
|
4
|
+
|
|
5
|
+
const width = ref(320)
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<Story title="Layout/LayoutResizeHandle">
|
|
10
|
+
<Variant title="Keyboard splitter">
|
|
11
|
+
<div style="display: flex; height: 16rem; padding: 2rem;">
|
|
12
|
+
<div :style="{ width: `${width}px`, background: 'var(--bg-secondary)', border: '1px solid var(--border-color)' }">
|
|
13
|
+
Panel width: {{ width }}px
|
|
14
|
+
</div>
|
|
15
|
+
<LayoutResizeHandle
|
|
16
|
+
v-model="width"
|
|
17
|
+
orientation="vertical"
|
|
18
|
+
label="Resize panel"
|
|
19
|
+
:min="240"
|
|
20
|
+
:max="480"
|
|
21
|
+
/>
|
|
22
|
+
<div style="flex: 1; background: var(--bg-tertiary);" />
|
|
23
|
+
</div>
|
|
24
|
+
</Variant>
|
|
25
|
+
</Story>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** Accessible pointer and keyboard handle for resizable workbench panes. */
|
|
3
|
+
interface Props {
|
|
4
|
+
modelValue: number
|
|
5
|
+
min: number
|
|
6
|
+
max: number
|
|
7
|
+
label: string
|
|
8
|
+
orientation: 'vertical' | 'horizontal'
|
|
9
|
+
edge?: 'left' | 'right'
|
|
10
|
+
step?: number
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
edge: undefined,
|
|
16
|
+
step: 10,
|
|
17
|
+
disabled: false,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
'update:modelValue': [value: number]
|
|
22
|
+
'resize-start': [event: PointerEvent]
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
function update(value: number): void {
|
|
26
|
+
emit('update:modelValue', Math.min(props.max, Math.max(props.min, value)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
30
|
+
if (props.disabled) return
|
|
31
|
+
const decrement = props.orientation === 'vertical' ? 'ArrowLeft' : 'ArrowUp'
|
|
32
|
+
const increment = props.orientation === 'vertical' ? 'ArrowRight' : 'ArrowDown'
|
|
33
|
+
if (event.key === decrement) update(props.modelValue - props.step)
|
|
34
|
+
else if (event.key === increment) update(props.modelValue + props.step)
|
|
35
|
+
else if (event.key === 'Home') update(props.min)
|
|
36
|
+
else if (event.key === 'End') update(props.max)
|
|
37
|
+
else return
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div
|
|
44
|
+
role="separator"
|
|
45
|
+
:tabindex="disabled ? -1 : 0"
|
|
46
|
+
:class="[
|
|
47
|
+
'mint-layout-resize-handle',
|
|
48
|
+
`mint-layout-resize-handle--${orientation}`,
|
|
49
|
+
edge ? `mint-layout-resize-handle--edge-${edge}` : '',
|
|
50
|
+
]"
|
|
51
|
+
:aria-label="label"
|
|
52
|
+
:aria-orientation="orientation"
|
|
53
|
+
:aria-valuemin="min"
|
|
54
|
+
:aria-valuemax="max"
|
|
55
|
+
:aria-valuenow="modelValue"
|
|
56
|
+
:aria-disabled="disabled || undefined"
|
|
57
|
+
@pointerdown="!disabled && emit('resize-start', $event)"
|
|
58
|
+
@keydown="handleKeydown"
|
|
59
|
+
>
|
|
60
|
+
<span class="mint-layout-resize-handle__bar" aria-hidden="true" />
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|