@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.0
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/README.md +30 -0
- package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
- package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DCXmwKNS.js} +5 -5
- package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
- package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
- package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
- package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
- package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
- package/dist/__tests__/apiErrors.test.d.ts +1 -0
- package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
- package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
- package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
- package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
- package/dist/apiErrors.d.ts +26 -0
- package/dist/components/BaseInput.vue.d.ts +1 -1
- package/dist/components/BaseSelect.vue.d.ts +1 -1
- package/dist/components/BaseTextarea.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
- package/dist/components/ConcentrationInput.vue.d.ts +1 -1
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +1 -1
- package/dist/components/FileBrowserModal.vue.d.ts +99 -0
- package/dist/components/FilePicker.vue.d.ts +32 -0
- package/dist/components/FileUploader.vue.d.ts +5 -2
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
- package/dist/components/MobileSupportGate.vue.d.ts +1 -1
- package/dist/components/MoleculeInput.vue.d.ts +1 -1
- package/dist/components/NumberInput.vue.d.ts +1 -1
- package/dist/components/PlotlyChart.vue.d.ts +59 -0
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SecretInput.vue.d.ts +1 -1
- package/dist/components/SequenceInput.vue.d.ts +1 -1
- package/dist/components/StatusIndicator.vue.d.ts +1 -1
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/TimePicker.vue.d.ts +1 -1
- package/dist/components/TimeRangeInput.vue.d.ts +1 -1
- package/dist/components/UnitInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +1 -1
- package/dist/components/fileBrowserPresentation.d.ts +10 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +8 -8
- package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
- package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
- package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
- package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
- package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
- package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
- package/dist/components-DIum5hkx.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +3 -0
- package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
- package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
- package/dist/composables/filePicker/validation.d.ts +5 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +10 -10
- package/dist/composables/useApi.d.ts +14 -7
- package/dist/composables/useFileBrowser.d.ts +55 -0
- package/dist/composables/useFilePicker.d.ts +189 -0
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
- package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
- package/dist/composables-mFXsTA4g.js.map +1 -0
- package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
- package/dist/errors-BLuqMee5.js.map +1 -0
- package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
- package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -15
- package/dist/install.js +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/styles.css +2369 -1377
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
- package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
- package/dist/types/fileBrowserTypes.d.ts +77 -0
- package/dist/types/filePicker.d.ts +77 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
- package/dist/useControlSchema-DldpxNOP.js.map +1 -0
- package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
- package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
- package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
- package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
- package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
- package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
- package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
- package/dist/validation-uwoJGEb6.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/apiErrors.test.ts +81 -0
- package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
- package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
- package/src/__tests__/components/FilePicker.state.test.ts +287 -0
- package/src/__tests__/components/FilePicker.test.ts +487 -0
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
- package/src/__tests__/components/PlotlyChart.test.ts +305 -0
- package/src/__tests__/components/SegmentedControl.test.ts +19 -0
- package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
- package/src/__tests__/composables/useApi.test.ts +92 -9
- package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
- package/src/__tests__/composables/usePluginClient.test.ts +3 -0
- package/src/apiErrors.ts +85 -0
- package/src/components/ExperimentPopover.vue +10 -2
- package/src/components/FileBrowserModal.story.vue +203 -0
- package/src/components/FileBrowserModal.vue +384 -0
- package/src/components/FilePicker.story.vue +44 -0
- package/src/components/FilePicker.vue +284 -0
- package/src/components/FileUploader.vue +32 -14
- package/src/components/GeneratedPlotlyResult.vue +34 -69
- package/src/components/GeneratedResultRenderer.vue +0 -5
- package/src/components/PlotlyChart.story.vue +61 -0
- package/src/components/PlotlyChart.vue +233 -0
- package/src/components/SegmentedControl.vue +2 -2
- package/src/components/fileBrowserPresentation.ts +39 -0
- package/src/components/index.ts +5 -0
- package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
- package/src/components/internal/FileBrowserListingInternal.vue +197 -0
- package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
- package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
- package/src/components/internal/FilePickerTreeInternal.vue +238 -0
- package/src/composables/filePicker/localFiles.ts +58 -0
- package/src/composables/filePicker/usePickerNavigation.ts +220 -0
- package/src/composables/filePicker/usePickerSelection.ts +259 -0
- package/src/composables/filePicker/validation.ts +41 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/useApi.ts +57 -13
- package/src/composables/useFileBrowser.ts +314 -0
- package/src/composables/useFilePicker.ts +366 -0
- package/src/composables/useGeneratedAnalysis.ts +10 -17
- package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
- package/src/composables/usePluginClient.ts +3 -0
- package/src/index.ts +3 -0
- package/src/styles/components/file-browser-modal.css +489 -0
- package/src/styles/components/file-picker.css +502 -0
- package/src/styles/components/icon-button.css +9 -0
- package/src/styles/index.css +3 -0
- package/src/types/fileBrowserTypes.ts +85 -0
- package/src/types/filePicker.ts +72 -0
- package/src/types/index.ts +14 -0
- package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
- package/dist/components-CgAUzF8F.js.map +0 -1
- package/dist/composables-B_pia3hO.js.map +0 -1
- package/dist/errors-_W0-LbA6.js.map +0 -1
- package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
- package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Render native Plotly data with MINT loading, empty, theme, and error states.
|
|
4
|
+
*
|
|
5
|
+
* Plotly is loaded only when the component is mounted, so importing the SDK does
|
|
6
|
+
* not add Plotly to the application's initial bundle.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```vue
|
|
10
|
+
* <PlotlyChart
|
|
11
|
+
* title="QC overview"
|
|
12
|
+
* :data="traces"
|
|
13
|
+
* :layout="{ xaxis: { title: { text: 'Sample' } } }"
|
|
14
|
+
* :config="{ toImageButtonOptions: { filename: 'qc-overview' } }"
|
|
15
|
+
* />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type * as PlotlyType from 'plotly.js'
|
|
19
|
+
import { nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
|
20
|
+
|
|
21
|
+
import ChartContainer from './ChartContainer.vue'
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
data: PlotlyType.Data[]
|
|
25
|
+
layout?: Partial<PlotlyType.Layout>
|
|
26
|
+
config?: Partial<PlotlyType.Config>
|
|
27
|
+
title?: string
|
|
28
|
+
description?: string
|
|
29
|
+
loading?: boolean
|
|
30
|
+
empty?: boolean
|
|
31
|
+
emptyMessage?: string
|
|
32
|
+
ariaLabel?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
36
|
+
layout: () => ({}),
|
|
37
|
+
config: () => ({}),
|
|
38
|
+
loading: false,
|
|
39
|
+
empty: false,
|
|
40
|
+
emptyMessage: 'No data available',
|
|
41
|
+
ariaLabel: 'Interactive Plotly chart',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const chartElement = ref<HTMLDivElement | null>(null)
|
|
45
|
+
const failed = ref(false)
|
|
46
|
+
let plotly: typeof PlotlyType | null = null
|
|
47
|
+
let resizeObserver: ResizeObserver | null = null
|
|
48
|
+
let themeObserver: MutationObserver | null = null
|
|
49
|
+
let renderedElement: HTMLDivElement | null = null
|
|
50
|
+
let rendering = false
|
|
51
|
+
let disposed = false
|
|
52
|
+
let revision = 0
|
|
53
|
+
|
|
54
|
+
async function loadPlotly(): Promise<typeof PlotlyType> {
|
|
55
|
+
const module = await import('plotly.js-dist-min')
|
|
56
|
+
return ('default' in module ? module.default : module) as unknown as typeof PlotlyType
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cssValue(name: string, fallback: string): string {
|
|
60
|
+
const value = getComputedStyle(chartElement.value ?? document.documentElement).getPropertyValue(name).trim()
|
|
61
|
+
return value || getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Unwrap nested Vue proxies while copying Plotly's mutable input tree. Keep
|
|
65
|
+
// native dates/typed arrays and callbacks instead of round-tripping through JSON.
|
|
66
|
+
function clonePlotlyInput<T>(value: T): T {
|
|
67
|
+
const raw = toRaw(value)
|
|
68
|
+
if (Array.isArray(raw)) return raw.map(clonePlotlyInput) as T
|
|
69
|
+
if (raw !== null && typeof raw === 'object') {
|
|
70
|
+
if (Object.getPrototypeOf(raw) === Object.prototype) {
|
|
71
|
+
return Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, clonePlotlyInput(item)])) as T
|
|
72
|
+
}
|
|
73
|
+
return structuredClone(raw)
|
|
74
|
+
}
|
|
75
|
+
return raw
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function themedLayout(): Partial<PlotlyType.Layout> {
|
|
79
|
+
const textColor = cssValue('--text-primary', '#1E293B')
|
|
80
|
+
const gridColor = cssValue('--border-color', '#E5E7EB')
|
|
81
|
+
const layout = clonePlotlyInput(props.layout)
|
|
82
|
+
return {
|
|
83
|
+
autosize: true,
|
|
84
|
+
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
85
|
+
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
86
|
+
...layout,
|
|
87
|
+
font: { color: textColor, family: cssValue('--font-sans', 'Arial, sans-serif'), ...layout.font },
|
|
88
|
+
xaxis: { gridcolor: gridColor, ...layout.xaxis },
|
|
89
|
+
yaxis: { gridcolor: gridColor, ...layout.yaxis },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function purgeChart(): void {
|
|
94
|
+
resizeObserver?.disconnect()
|
|
95
|
+
if (renderedElement) plotly?.purge(renderedElement)
|
|
96
|
+
renderedElement = null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function renderChart(): Promise<void> {
|
|
100
|
+
revision++
|
|
101
|
+
if (rendering || disposed || props.loading || props.empty) return
|
|
102
|
+
rendering = true
|
|
103
|
+
try {
|
|
104
|
+
while (!disposed && !props.loading && !props.empty) {
|
|
105
|
+
const currentRevision = revision
|
|
106
|
+
failed.value = false
|
|
107
|
+
await nextTick()
|
|
108
|
+
if (disposed || props.loading || props.empty) break
|
|
109
|
+
try {
|
|
110
|
+
plotly ??= await loadPlotly()
|
|
111
|
+
if (disposed || props.loading || props.empty) break
|
|
112
|
+
if (currentRevision !== revision) continue
|
|
113
|
+
const element = chartElement.value
|
|
114
|
+
if (!element) break
|
|
115
|
+
renderedElement = element
|
|
116
|
+
// Plotly mutates traces and nested layout fields; keep caller-owned values intact.
|
|
117
|
+
await plotly.react(
|
|
118
|
+
element,
|
|
119
|
+
clonePlotlyInput(props.data),
|
|
120
|
+
themedLayout(),
|
|
121
|
+
{ responsive: true, displaylogo: false, ...props.config },
|
|
122
|
+
)
|
|
123
|
+
if (disposed || props.loading || props.empty || element !== chartElement.value) {
|
|
124
|
+
purgeChart()
|
|
125
|
+
} else {
|
|
126
|
+
resizeObserver?.observe(element)
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
purgeChart()
|
|
130
|
+
if (!disposed && currentRevision === revision) failed.value = true
|
|
131
|
+
}
|
|
132
|
+
if (currentRevision === revision) break
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
rendering = false
|
|
136
|
+
if (disposed || props.loading || props.empty) purgeChart()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMounted(() => {
|
|
141
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
142
|
+
resizeObserver = new ResizeObserver(() => {
|
|
143
|
+
if (rendering || !plotly || !renderedElement) return
|
|
144
|
+
// A tab can become hidden or unmount before Plotly's async resize finishes.
|
|
145
|
+
void Promise.resolve(plotly.Plots.resize(renderedElement)).catch(() => {})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
150
|
+
themeObserver = new MutationObserver(() => void renderChart())
|
|
151
|
+
themeObserver.observe(document.documentElement, {
|
|
152
|
+
attributes: true,
|
|
153
|
+
attributeFilter: ['class', 'style'],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
void renderChart()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
watch(
|
|
160
|
+
[() => props.data, () => props.layout, () => props.config, () => props.loading, () => props.empty],
|
|
161
|
+
() => {
|
|
162
|
+
if (props.loading || props.empty) {
|
|
163
|
+
resizeObserver?.disconnect()
|
|
164
|
+
if (!rendering) purgeChart()
|
|
165
|
+
}
|
|
166
|
+
void renderChart()
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
onBeforeUnmount(() => {
|
|
171
|
+
disposed = true
|
|
172
|
+
resizeObserver?.disconnect()
|
|
173
|
+
themeObserver?.disconnect()
|
|
174
|
+
// An in-flight react must finish before its resources can be purged completely.
|
|
175
|
+
if (!rendering) purgeChart()
|
|
176
|
+
})
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<template>
|
|
180
|
+
<ChartContainer
|
|
181
|
+
:title="title"
|
|
182
|
+
:description="description"
|
|
183
|
+
:loading="loading"
|
|
184
|
+
:empty="empty"
|
|
185
|
+
:empty-message="emptyMessage"
|
|
186
|
+
>
|
|
187
|
+
<template v-if="$slots.toolbar" #toolbar>
|
|
188
|
+
<slot name="toolbar" />
|
|
189
|
+
</template>
|
|
190
|
+
<template v-if="$slots.empty" #empty>
|
|
191
|
+
<slot name="empty" />
|
|
192
|
+
</template>
|
|
193
|
+
|
|
194
|
+
<template v-if="failed">
|
|
195
|
+
<slot name="error">
|
|
196
|
+
<div class="mint-plotly-chart__error">
|
|
197
|
+
<p role="alert">Unable to display this chart.</p>
|
|
198
|
+
</div>
|
|
199
|
+
</slot>
|
|
200
|
+
</template>
|
|
201
|
+
<div
|
|
202
|
+
ref="chartElement"
|
|
203
|
+
v-show="!failed"
|
|
204
|
+
class="mint-plotly-chart__plot"
|
|
205
|
+
role="group"
|
|
206
|
+
:aria-label="ariaLabel"
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<template v-if="$slots.legend" #legend>
|
|
210
|
+
<slot name="legend" />
|
|
211
|
+
</template>
|
|
212
|
+
</ChartContainer>
|
|
213
|
+
</template>
|
|
214
|
+
|
|
215
|
+
<style scoped>
|
|
216
|
+
.mint-plotly-chart__plot {
|
|
217
|
+
width: 100%;
|
|
218
|
+
min-height: 24rem;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.mint-plotly-chart__error {
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 1rem;
|
|
224
|
+
border: 1px solid var(--mint-error-border);
|
|
225
|
+
border-radius: 0.75rem;
|
|
226
|
+
background: var(--mint-error-bg);
|
|
227
|
+
color: var(--mint-error-hover);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.mint-plotly-chart__error :deep(p) {
|
|
231
|
+
margin: 0;
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
/** Renders a segmented button group for single-option selection,
|
|
2
|
+
/** Renders a segmented button group for single-option selection, defaulting to the card variant. */
|
|
3
3
|
import { computed, type ComponentPublicInstance } from 'vue'
|
|
4
4
|
import type { SegmentedOption, SegmentedOptionInput, SegmentedControlVariant, SegmentedControlSize } from '../types'
|
|
5
5
|
import { normalizeOptionInput } from '../utils/options'
|
|
@@ -16,7 +16,7 @@ interface Props {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
-
variant: '
|
|
19
|
+
variant: 'card',
|
|
20
20
|
size: 'md',
|
|
21
21
|
fullWidth: false,
|
|
22
22
|
disabled: false,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FileEntry } from '../types/fileBrowserTypes'
|
|
2
|
+
|
|
3
|
+
export const FILE_BROWSER_ICONS = {
|
|
4
|
+
disk: 'M4 5h16v14H4zM8 9h.01M4 12h16',
|
|
5
|
+
folder: 'M4 7h6l2 2h8v10H4z',
|
|
6
|
+
file: 'M14 3v5h5M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatCount(value: number): string {
|
|
10
|
+
// Thin spaces group digits without the comma/period ambiguity across locales.
|
|
11
|
+
return value.toLocaleString('en-US').replace(/,/g, ' ')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatSize(entry: FileEntry): string {
|
|
15
|
+
if (entry.kind === 'directory') {
|
|
16
|
+
return entry.child_count === null || entry.child_count === undefined
|
|
17
|
+
? '—'
|
|
18
|
+
: `${entry.child_count} item${entry.child_count === 1 ? '' : 's'}`
|
|
19
|
+
}
|
|
20
|
+
return formatBytes(entry.size_bytes ?? null)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatBytes(bytes: number | null): string {
|
|
24
|
+
if (bytes === null) return '—'
|
|
25
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
26
|
+
let value = bytes
|
|
27
|
+
let unit = 0
|
|
28
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
29
|
+
value /= 1024
|
|
30
|
+
unit += 1
|
|
31
|
+
}
|
|
32
|
+
const rounded = value >= 100 || unit === 0 ? Math.round(value) : Number(value.toFixed(1))
|
|
33
|
+
return `${rounded} ${units[unit]}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function entryType(entry: FileEntry): string {
|
|
37
|
+
if (entry.kind === 'directory') return 'Folder'
|
|
38
|
+
return entry.extension ? entry.extension.replace('.', '').toUpperCase() : 'File'
|
|
39
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -99,6 +99,7 @@ export { default as SampleHierarchyTree } from './SampleHierarchyTree.vue'
|
|
|
99
99
|
export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
|
|
100
100
|
|
|
101
101
|
// Scientific display components
|
|
102
|
+
export { default as PlotlyChart } from './PlotlyChart.vue'
|
|
102
103
|
export { default as ScientificNumber } from './ScientificNumber.vue'
|
|
103
104
|
/** Renders a chemical formula string (H₂O, C₆H₁₂O₆, Ca(OH)₂) with typographically correct subscripts and superscripts for charges. @deprecated Unused by the MINT platform and scheduled for removal in MINT 1.3; no platform replacement is planned. */
|
|
104
105
|
export { default as ChemicalFormula } from './ChemicalFormula.vue'
|
|
@@ -142,7 +143,11 @@ export { default as SectionCard } from './SectionCard.vue'
|
|
|
142
143
|
// Experiment / analysis components
|
|
143
144
|
export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
|
|
144
145
|
export { default as ArtifactSelectorModal } from './ArtifactSelectorModal.vue'
|
|
146
|
+
/** Modal picker over the read-only server mounts, recording paths rather than copying data. */
|
|
147
|
+
export { default as FileBrowserModal } from './FileBrowserModal.vue'
|
|
145
148
|
export { default as GeneratedPathInput } from './GeneratedPathInput.vue'
|
|
146
149
|
export { default as ArtifactSaveDialog } from './ArtifactSaveDialog.vue'
|
|
147
150
|
export { default as ExperimentPopover } from './ExperimentPopover.vue'
|
|
148
151
|
export { default as FitPanel } from './FitPanel.vue'
|
|
152
|
+
|
|
153
|
+
export { default as FilePicker } from './FilePicker.vue'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
|
3
|
+
import type { ModalSize } from '../../types'
|
|
4
|
+
import { useFocusTrap } from '../../composables/useFocusTrap'
|
|
5
|
+
import IconButton from '../IconButton.vue'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ open: boolean; label: string; size: ModalSize; variant?: 'picker' }>()
|
|
8
|
+
const emit = defineEmits<{ close: [] }>()
|
|
9
|
+
const containerRef = shallowRef<HTMLElement | null>(null)
|
|
10
|
+
const active = shallowRef(false)
|
|
11
|
+
useFocusTrap({ containerRef, active })
|
|
12
|
+
|
|
13
|
+
// Activate after mounting so an initially open picker traps focus too.
|
|
14
|
+
onMounted(() => {
|
|
15
|
+
active.value = props.open
|
|
16
|
+
})
|
|
17
|
+
watch(
|
|
18
|
+
() => props.open,
|
|
19
|
+
(open) => {
|
|
20
|
+
active.value = open
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
watch(
|
|
24
|
+
active,
|
|
25
|
+
(open, _, onCleanup) => {
|
|
26
|
+
if (!open) return
|
|
27
|
+
const overflow = document.body.style.overflow
|
|
28
|
+
document.body.style.overflow = 'hidden'
|
|
29
|
+
onCleanup(() => {
|
|
30
|
+
document.body.style.overflow = overflow
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
{ flush: 'sync' },
|
|
34
|
+
)
|
|
35
|
+
onBeforeUnmount(() => {
|
|
36
|
+
active.value = false
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<Teleport to="body">
|
|
42
|
+
<div v-if="open" class="mint-file-browser-overlay">
|
|
43
|
+
<section
|
|
44
|
+
ref="containerRef"
|
|
45
|
+
class="mint-file-browser-dialog"
|
|
46
|
+
:class="[
|
|
47
|
+
`mint-file-browser-dialog--${size}`,
|
|
48
|
+
variant === 'picker' ? 'mint-picker-dialog' : '',
|
|
49
|
+
]"
|
|
50
|
+
role="dialog"
|
|
51
|
+
aria-modal="true"
|
|
52
|
+
:aria-label="label"
|
|
53
|
+
tabindex="-1"
|
|
54
|
+
@keydown.esc.stop.prevent="emit('close')"
|
|
55
|
+
>
|
|
56
|
+
<header class="mint-file-browser-dialog__header">
|
|
57
|
+
<slot name="header" />
|
|
58
|
+
<IconButton size="sm" label="Close modal" @click="emit('close')">
|
|
59
|
+
<svg
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="currentColor"
|
|
63
|
+
stroke-width="2"
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
>
|
|
66
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
67
|
+
</svg>
|
|
68
|
+
</IconButton>
|
|
69
|
+
</header>
|
|
70
|
+
<slot />
|
|
71
|
+
<footer class="mint-file-browser-dialog__footer"><slot name="footer" /></footer>
|
|
72
|
+
</section>
|
|
73
|
+
</div>
|
|
74
|
+
</Teleport>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { FileEntry, FileSelection } from '../../types/fileBrowserTypes'
|
|
4
|
+
import {
|
|
5
|
+
FILE_BROWSER_ICONS as ICON,
|
|
6
|
+
formatCount,
|
|
7
|
+
formatSize,
|
|
8
|
+
entryType,
|
|
9
|
+
} from '../fileBrowserPresentation'
|
|
10
|
+
import EmptyState from '../EmptyState.vue'
|
|
11
|
+
import BaseButton from '../BaseButton.vue'
|
|
12
|
+
import IconButton from '../IconButton.vue'
|
|
13
|
+
import Skeleton from '../Skeleton.vue'
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
mountId: string
|
|
16
|
+
entries: FileEntry[]
|
|
17
|
+
selected: FileSelection[]
|
|
18
|
+
loading: boolean
|
|
19
|
+
error?: string | null
|
|
20
|
+
dimNonMatching: boolean
|
|
21
|
+
totalCount: number | null
|
|
22
|
+
matchCount: number | null
|
|
23
|
+
truncated: boolean
|
|
24
|
+
hasMounts: boolean
|
|
25
|
+
hasAvailableMounts: boolean
|
|
26
|
+
search: string
|
|
27
|
+
}>()
|
|
28
|
+
const emit = defineEmits<{ toggle: [entry: FileEntry]; open: [entry: FileEntry]; refresh: [] }>()
|
|
29
|
+
const emptyTitle = computed(() =>
|
|
30
|
+
!props.hasMounts
|
|
31
|
+
? 'No server mounts configured'
|
|
32
|
+
: !props.hasAvailableMounts
|
|
33
|
+
? 'Server mounts are offline'
|
|
34
|
+
: props.search
|
|
35
|
+
? 'No matching files or folders'
|
|
36
|
+
: 'This folder is empty',
|
|
37
|
+
)
|
|
38
|
+
const emptyDescription = computed(() =>
|
|
39
|
+
!props.hasMounts
|
|
40
|
+
? 'Ask your administrator to configure a read-only data mount.'
|
|
41
|
+
: !props.hasAvailableMounts
|
|
42
|
+
? 'Refresh when the data mount is available again.'
|
|
43
|
+
: props.search
|
|
44
|
+
? 'Try a different search or clear the search field.'
|
|
45
|
+
: 'Choose another folder from the mount list or breadcrumbs.',
|
|
46
|
+
)
|
|
47
|
+
function isSelected(entry: FileEntry) {
|
|
48
|
+
return props.selected.some((item) => item.mount_id === props.mountId && item.path === entry.path)
|
|
49
|
+
}
|
|
50
|
+
function isDimmed(entry: FileEntry) {
|
|
51
|
+
return props.dimNonMatching && entry.matches_type_rules === false
|
|
52
|
+
}
|
|
53
|
+
function entryIcon(entry: FileEntry) {
|
|
54
|
+
return entry.kind === 'directory' ? ICON.folder : ICON.file
|
|
55
|
+
}
|
|
56
|
+
function handleRowClick(entry: FileEntry) {
|
|
57
|
+
if (!props.loading && !props.error && props.mountId && entry.kind === 'directory')
|
|
58
|
+
emit('open', entry)
|
|
59
|
+
}
|
|
60
|
+
function toggleEntry(entry: FileEntry) {
|
|
61
|
+
if (!props.loading && !props.error && props.mountId) emit('toggle', entry)
|
|
62
|
+
}
|
|
63
|
+
function folderNote(entry: FileEntry) {
|
|
64
|
+
if (entry.kind !== 'directory' || !isSelected(entry)) return ''
|
|
65
|
+
return entry.child_count == null
|
|
66
|
+
? 'whole folder'
|
|
67
|
+
: `whole folder, ${entry.child_count} item${entry.child_count === 1 ? '' : 's'}`
|
|
68
|
+
}
|
|
69
|
+
const statusLine = computed(() => {
|
|
70
|
+
if (props.loading) return 'Loading folder…'
|
|
71
|
+
if (props.error || !props.mountId) return ''
|
|
72
|
+
const total = props.totalCount ?? props.entries.length
|
|
73
|
+
const parts = [`${formatCount(total)} item${total === 1 ? '' : 's'}`]
|
|
74
|
+
if (props.entries.length !== total) parts.push(`${formatCount(props.entries.length)} shown`)
|
|
75
|
+
if (props.matchCount !== null && props.matchCount !== total)
|
|
76
|
+
parts.push(`${formatCount(props.matchCount)} match the type rules`)
|
|
77
|
+
if (props.truncated) parts.push('listing capped')
|
|
78
|
+
return parts.join(' · ')
|
|
79
|
+
})
|
|
80
|
+
</script>
|
|
81
|
+
<template>
|
|
82
|
+
<div class="mint-file-browser__listing" :aria-busy="loading">
|
|
83
|
+
<div class="mint-file-browser__head">
|
|
84
|
+
<span class="mint-file-browser__col-check" />
|
|
85
|
+
<span class="mint-file-browser__col-icon" />
|
|
86
|
+
<span class="mint-file-browser__col-name">Name</span>
|
|
87
|
+
<span class="mint-file-browser__col-size">Size</span>
|
|
88
|
+
<span class="mint-file-browser__col-type">Type</span>
|
|
89
|
+
<span class="mint-file-browser__col-open" />
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div v-if="loading" class="mint-file-browser__skeleton">
|
|
93
|
+
<div v-for="n in 6" :key="n" class="mint-file-browser__skeleton-row">
|
|
94
|
+
<Skeleton width="60%" height="14px" />
|
|
95
|
+
<Skeleton width="60px" height="10px" />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-else-if="error" class="mint-file-browser__error" role="alert">
|
|
100
|
+
<span>{{ error }}</span>
|
|
101
|
+
<BaseButton
|
|
102
|
+
variant="secondary"
|
|
103
|
+
size="sm"
|
|
104
|
+
class="mint-file-browser__retry"
|
|
105
|
+
aria-label="Retry folder listing"
|
|
106
|
+
@click="emit('refresh')"
|
|
107
|
+
>
|
|
108
|
+
Try again
|
|
109
|
+
</BaseButton>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<EmptyState
|
|
113
|
+
v-else-if="!entries.length"
|
|
114
|
+
:title="emptyTitle"
|
|
115
|
+
:description="emptyDescription"
|
|
116
|
+
size="sm"
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
<div v-else class="mint-file-browser__rows">
|
|
120
|
+
<div
|
|
121
|
+
v-for="entry in entries"
|
|
122
|
+
:key="entry.path"
|
|
123
|
+
class="mint-file-browser__row"
|
|
124
|
+
:class="{
|
|
125
|
+
'mint-file-browser__row--selected': isSelected(entry),
|
|
126
|
+
'mint-file-browser__row--dimmed': isDimmed(entry),
|
|
127
|
+
'mint-file-browser__row--dir': entry.kind === 'directory',
|
|
128
|
+
}"
|
|
129
|
+
@click="handleRowClick(entry)"
|
|
130
|
+
>
|
|
131
|
+
<input
|
|
132
|
+
class="mint-file-browser__checkbox"
|
|
133
|
+
type="checkbox"
|
|
134
|
+
:checked="isSelected(entry)"
|
|
135
|
+
:title="entry.kind === 'directory' ? 'Select the whole folder' : 'Select this file'"
|
|
136
|
+
:aria-label="
|
|
137
|
+
entry.kind === 'directory'
|
|
138
|
+
? `Select the whole ${entry.name} folder`
|
|
139
|
+
: `Select ${entry.name}`
|
|
140
|
+
"
|
|
141
|
+
@click.stop
|
|
142
|
+
@change="toggleEntry(entry)"
|
|
143
|
+
/>
|
|
144
|
+
<span
|
|
145
|
+
class="mint-file-browser__row-icon"
|
|
146
|
+
:class="`mint-file-browser__row-icon--${entry.kind}`"
|
|
147
|
+
>
|
|
148
|
+
<svg
|
|
149
|
+
class="mint-file-browser__icon"
|
|
150
|
+
viewBox="0 0 24 24"
|
|
151
|
+
fill="none"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
stroke-width="1.8"
|
|
154
|
+
stroke-linecap="round"
|
|
155
|
+
stroke-linejoin="round"
|
|
156
|
+
>
|
|
157
|
+
<path :d="entryIcon(entry)" />
|
|
158
|
+
</svg>
|
|
159
|
+
</span>
|
|
160
|
+
<span class="mint-file-browser__name-cell">
|
|
161
|
+
<span class="mint-file-browser__name" :title="entry.name">{{ entry.name }}</span>
|
|
162
|
+
<span v-if="folderNote(entry)" class="mint-file-browser__note-pill">Whole folder</span>
|
|
163
|
+
</span>
|
|
164
|
+
<span class="mint-file-browser__size">{{ formatSize(entry) }}</span>
|
|
165
|
+
<span class="mint-file-browser__type">{{ entryType(entry) }}</span>
|
|
166
|
+
<!-- The chevron is the keyboard route into a folder: the row
|
|
167
|
+
itself is a div, so this button is what Tab can reach. -->
|
|
168
|
+
<IconButton
|
|
169
|
+
v-if="entry.kind === 'directory'"
|
|
170
|
+
size="sm"
|
|
171
|
+
class="mint-file-browser__open"
|
|
172
|
+
:label="`Open ${entry.name}`"
|
|
173
|
+
@click.stop="handleRowClick(entry)"
|
|
174
|
+
>
|
|
175
|
+
<svg
|
|
176
|
+
class="mint-file-browser__icon"
|
|
177
|
+
viewBox="0 0 24 24"
|
|
178
|
+
fill="none"
|
|
179
|
+
stroke="currentColor"
|
|
180
|
+
stroke-width="2.5"
|
|
181
|
+
stroke-linecap="round"
|
|
182
|
+
stroke-linejoin="round"
|
|
183
|
+
>
|
|
184
|
+
<path d="M9 6l6 6-6 6" />
|
|
185
|
+
</svg>
|
|
186
|
+
</IconButton>
|
|
187
|
+
<span v-else class="mint-file-browser__open" aria-hidden="true" />
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="mint-file-browser__status" role="status" aria-live="polite">
|
|
192
|
+
<span>{{ statusLine }}</span>
|
|
193
|
+
<div class="mint-file-browser__status-spacer" />
|
|
194
|
+
<span class="mint-file-browser__legend">Read-only · Select paths, not copies</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</template>
|