@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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.2.1",
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 not scrub when the pointer goes down on the input itself', async () => {
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
- <div v-if="title || description" class="mint-chart__header-text">
25
- <h3 v-if="title" class="mint-chart__title">{{ title }}</h3>
26
- <p v-if="description" class="mint-chart__description">{{ description }}</p>
27
- </div>
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 string (H₂O, C₆H₁₂O₆, Ca(OH)₂) with typographically correct subscripts and superscripts for charges. */
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: string
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="['mint-chem-formula', !inline ? 'mint-chem-formula--block' : '']"
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-for="(part, i) in getParts(formula)" :key="i">
27
- <span v-if="part.type === 'element'" class="mint-chem-formula__element">{{ part.text }}</span>
28
- <span v-else-if="part.type === 'subscript'" class="mint-chem-formula__subscript">{{ part.text }}</span>
29
- <span v-else-if="part.type === 'superscript'" class="mint-chem-formula__superscript">{{ part.text }}</span>
30
- <span v-else-if="part.type === 'paren'" class="mint-chem-formula__paren">{{ part.text }}</span>
31
- <span v-else-if="part.type === 'dot'" class="mint-chem-formula__dot">{{ part.text }}</span>
32
- <span v-else-if="part.type === 'charge'" class="mint-chem-formula__charge">{{ part.text }}</span>
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>