@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,136 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import BaseButton from '../BaseButton.vue'
|
|
4
|
+
import BaseInput from '../BaseInput.vue'
|
|
5
|
+
import IconButton from '../IconButton.vue'
|
|
6
|
+
import type {
|
|
7
|
+
FileBrowserSortKey,
|
|
8
|
+
FilePathCrumb,
|
|
9
|
+
FileBrowserLocation,
|
|
10
|
+
} from '../../types/fileBrowserTypes'
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
mountId: string
|
|
13
|
+
path: string
|
|
14
|
+
parent: string | null
|
|
15
|
+
breadcrumbs: FilePathCrumb[]
|
|
16
|
+
search: string
|
|
17
|
+
sort: FileBrowserSortKey
|
|
18
|
+
typeRules: string[]
|
|
19
|
+
loading: boolean
|
|
20
|
+
error?: string | null
|
|
21
|
+
}>()
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
navigate: [location: FileBrowserLocation]
|
|
24
|
+
selectFolder: [location: FileBrowserLocation]
|
|
25
|
+
'update:search': [value: string]
|
|
26
|
+
'update:sort': [value: FileBrowserSortKey]
|
|
27
|
+
refresh: []
|
|
28
|
+
}>()
|
|
29
|
+
const typeRuleLabel = computed(() => props.typeRules.join(' '))
|
|
30
|
+
function handleBack() {
|
|
31
|
+
if (props.parent !== null && !props.loading && !props.error)
|
|
32
|
+
emit('navigate', { mount_id: props.mountId, path: props.parent })
|
|
33
|
+
}
|
|
34
|
+
function handleCrumb(crumb: FilePathCrumb) {
|
|
35
|
+
emit('navigate', { mount_id: props.mountId, path: crumb.path })
|
|
36
|
+
}
|
|
37
|
+
function cycleSort() {
|
|
38
|
+
const order: FileBrowserSortKey[] = ['name', 'size', 'modified']
|
|
39
|
+
emit('update:sort', order[(order.indexOf(props.sort) + 1) % order.length]!)
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
<template>
|
|
43
|
+
<div class="mint-file-browser__toolbar">
|
|
44
|
+
<div class="mint-file-browser__pathbar">
|
|
45
|
+
<IconButton
|
|
46
|
+
size="sm"
|
|
47
|
+
class="mint-file-browser__back"
|
|
48
|
+
label="Go to parent folder"
|
|
49
|
+
:disabled="parent === null || loading || !!error"
|
|
50
|
+
@click="handleBack"
|
|
51
|
+
>
|
|
52
|
+
<svg
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
fill="none"
|
|
55
|
+
stroke="currentColor"
|
|
56
|
+
stroke-width="2"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
>
|
|
59
|
+
<path d="M12 19V5m-6 6 6-6 6 6" />
|
|
60
|
+
</svg>
|
|
61
|
+
</IconButton>
|
|
62
|
+
<nav class="mint-file-browser__crumbs" aria-label="Breadcrumb">
|
|
63
|
+
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
|
64
|
+
<span v-if="index" class="mint-file-browser__crumb-sep" aria-hidden="true">/</span>
|
|
65
|
+
<BaseButton
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="sm"
|
|
68
|
+
class="mint-file-browser__crumb"
|
|
69
|
+
:class="{ 'mint-file-browser__crumb--current': index === breadcrumbs.length - 1 }"
|
|
70
|
+
:aria-current="index === breadcrumbs.length - 1 ? 'location' : undefined"
|
|
71
|
+
@click="handleCrumb(crumb)"
|
|
72
|
+
>{{ crumb.label }}</BaseButton
|
|
73
|
+
>
|
|
74
|
+
</template>
|
|
75
|
+
<span v-if="!breadcrumbs.length" class="mint-file-browser__selection-hint">{{
|
|
76
|
+
path || 'Choose a location'
|
|
77
|
+
}}</span>
|
|
78
|
+
</nav>
|
|
79
|
+
<IconButton
|
|
80
|
+
size="sm"
|
|
81
|
+
label="Refresh folder listing"
|
|
82
|
+
:disabled="loading"
|
|
83
|
+
@click="emit('refresh')"
|
|
84
|
+
>
|
|
85
|
+
<svg
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
stroke-width="2"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
<path d="M20 7v5h-5M4 17v-5h5M6 8a7 7 0 0 1 12-2l2 3M4 15l2 3a7 7 0 0 0 12-2" />
|
|
93
|
+
</svg>
|
|
94
|
+
</IconButton>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="mint-file-browser__tools">
|
|
97
|
+
<BaseInput
|
|
98
|
+
:model-value="search"
|
|
99
|
+
type="search"
|
|
100
|
+
size="sm"
|
|
101
|
+
class="mint-file-browser__search"
|
|
102
|
+
placeholder="Search this folder…"
|
|
103
|
+
aria-label="Search this folder"
|
|
104
|
+
@update:model-value="emit('update:search', String($event ?? ''))"
|
|
105
|
+
/>
|
|
106
|
+
<span v-if="typeRuleLabel" class="mint-file-browser__rules" title="Matching file types">{{
|
|
107
|
+
typeRuleLabel
|
|
108
|
+
}}</span>
|
|
109
|
+
<IconButton
|
|
110
|
+
size="sm"
|
|
111
|
+
class="mint-file-browser__sort"
|
|
112
|
+
:label="`Sorted by ${sort}. Click to change.`"
|
|
113
|
+
@click="cycleSort"
|
|
114
|
+
>
|
|
115
|
+
<svg
|
|
116
|
+
viewBox="0 0 24 24"
|
|
117
|
+
fill="none"
|
|
118
|
+
stroke="currentColor"
|
|
119
|
+
stroke-width="2"
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
>
|
|
122
|
+
<path d="M4 6h10M4 12h16M4 18h7" />
|
|
123
|
+
</svg>
|
|
124
|
+
</IconButton>
|
|
125
|
+
<BaseButton
|
|
126
|
+
variant="secondary"
|
|
127
|
+
size="sm"
|
|
128
|
+
class="mint-file-browser__select-folder"
|
|
129
|
+
:title="`Select ${path || 'this folder'} itself as the input`"
|
|
130
|
+
:disabled="!mountId || loading || !!error"
|
|
131
|
+
@click="emit('selectFolder', { mount_id: mountId, path })"
|
|
132
|
+
>Select this folder</BaseButton
|
|
133
|
+
>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BaseButton from '../BaseButton.vue'
|
|
3
|
+
import { FILE_BROWSER_ICONS as ICON, formatBytes } from '../fileBrowserPresentation'
|
|
4
|
+
import type { PickerNode, PickerExcluded, PickerSource } from '../../types/filePicker'
|
|
5
|
+
import { pickerReason } from '../../composables/filePicker/validation'
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
totalCount?: number | null
|
|
9
|
+
node: PickerNode | null
|
|
10
|
+
source: PickerSource
|
|
11
|
+
exclusions: PickerExcluded[]
|
|
12
|
+
busy: boolean
|
|
13
|
+
error: string
|
|
14
|
+
reviewing: boolean
|
|
15
|
+
}>()
|
|
16
|
+
const emit = defineEmits<{ review: []; retry: [] }>()
|
|
17
|
+
</script>
|
|
18
|
+
<template>
|
|
19
|
+
<aside class="mint-picker__preview" aria-label="Selection preview">
|
|
20
|
+
<div class="mint-picker__preview-heading">{{ reviewing ? 'Skipped files' : 'Preview' }}</div>
|
|
21
|
+
<div v-if="reviewing" class="mint-picker__preview-content">
|
|
22
|
+
<BaseButton variant="ghost" size="sm" @click="emit('review')">Back to preview</BaseButton>
|
|
23
|
+
<div
|
|
24
|
+
v-for="item in exclusions"
|
|
25
|
+
:key="item.path"
|
|
26
|
+
class="mint-picker__exclusion"
|
|
27
|
+
:title="item.path"
|
|
28
|
+
>
|
|
29
|
+
<strong>{{ item.name }}</strong
|
|
30
|
+
><span>{{ pickerReason(item) }}</span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div v-else-if="node" class="mint-picker__preview-content">
|
|
34
|
+
<div class="mint-picker__preview-icon">
|
|
35
|
+
<svg
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
stroke-width="1.7"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
>
|
|
42
|
+
<path :d="node.kind === 'folder' ? ICON.folder : ICON.file" />
|
|
43
|
+
</svg>
|
|
44
|
+
</div>
|
|
45
|
+
<strong class="mint-picker__preview-name">{{ node.name }}</strong>
|
|
46
|
+
<dl class="mint-picker__metadata">
|
|
47
|
+
<div>
|
|
48
|
+
<dt>Location</dt>
|
|
49
|
+
<dd :title="node.parentName || node.path">{{ node.parentName || node.path }}</dd>
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<dt>Size</dt>
|
|
53
|
+
<dd>{{ node.bytes == null ? 'Not available' : formatBytes(node.bytes) }}</dd>
|
|
54
|
+
</div>
|
|
55
|
+
<div v-if="node.kind === 'folder'">
|
|
56
|
+
<dt>Files</dt>
|
|
57
|
+
<dd>
|
|
58
|
+
{{ node.fileCount ?? 'Not counted yet'
|
|
59
|
+
}}<template v-if="totalCount != null && totalCount !== node.fileCount">
|
|
60
|
+
of {{ totalCount }}</template
|
|
61
|
+
>
|
|
62
|
+
</dd>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<dt>Format</dt>
|
|
66
|
+
<dd v-if="node.kind === 'file'">{{ node.format.toUpperCase() || 'Unknown' }}</dd>
|
|
67
|
+
<dd v-else>
|
|
68
|
+
<span v-for="(count, format) in node.formats" :key="format"
|
|
69
|
+
>{{ format.toUpperCase() }} ×{{ count }}<br /></span
|
|
70
|
+
><span v-if="!node.formats">Not counted yet</span>
|
|
71
|
+
</dd>
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
<dt>Source</dt>
|
|
75
|
+
<dd>
|
|
76
|
+
{{ source === 'localFile' ? 'Local files' : 'Server' }}
|
|
77
|
+
</dd>
|
|
78
|
+
</div>
|
|
79
|
+
</dl>
|
|
80
|
+
<BaseButton
|
|
81
|
+
v-if="exclusions.length"
|
|
82
|
+
variant="ghost"
|
|
83
|
+
size="sm"
|
|
84
|
+
class="mint-picker__skipped"
|
|
85
|
+
@click="emit('review')"
|
|
86
|
+
>{{ exclusions.length }} skipped · Review</BaseButton
|
|
87
|
+
>
|
|
88
|
+
<div v-if="error" class="mint-picker__preview-error" role="alert">
|
|
89
|
+
{{ error
|
|
90
|
+
}}<BaseButton variant="secondary" size="sm" @click="emit('retry')">Try again</BaseButton>
|
|
91
|
+
</div>
|
|
92
|
+
<span v-else class="mint-picker__ready" role="status">{{
|
|
93
|
+
busy ? 'Checking selection…' : '✓ Metadata available'
|
|
94
|
+
}}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div v-else class="mint-picker__preview-empty">
|
|
97
|
+
Select a file or folder to preview its details.
|
|
98
|
+
</div>
|
|
99
|
+
</aside>
|
|
100
|
+
</template>
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, ref } from 'vue'
|
|
3
|
+
import IconButton from '../IconButton.vue'
|
|
4
|
+
import BaseButton from '../BaseButton.vue'
|
|
5
|
+
import Skeleton from '../Skeleton.vue'
|
|
6
|
+
import { FILE_BROWSER_ICONS as ICON, formatBytes } from '../fileBrowserPresentation'
|
|
7
|
+
import type { PickerNode, PickerTreeRow, PickerSelectionMode } from '../../types/filePicker'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
emptyTitle?: string
|
|
11
|
+
rows: PickerTreeRow[]
|
|
12
|
+
loading: boolean
|
|
13
|
+
error: string
|
|
14
|
+
query: string
|
|
15
|
+
mode: PickerSelectionMode
|
|
16
|
+
selected: (node: PickerNode) => boolean
|
|
17
|
+
reason: (node: PickerNode) => string
|
|
18
|
+
progress: (node: PickerNode) => string
|
|
19
|
+
focusPath: string
|
|
20
|
+
}>()
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
select: [node: PickerNode, event: MouseEvent | KeyboardEvent]
|
|
23
|
+
enter: [node: PickerNode]
|
|
24
|
+
expand: [node: PickerNode]
|
|
25
|
+
up: []
|
|
26
|
+
retry: []
|
|
27
|
+
parent: [node: PickerNode]
|
|
28
|
+
}>()
|
|
29
|
+
const list = ref<HTMLElement | null>(null)
|
|
30
|
+
const focusablePath = computed(() => {
|
|
31
|
+
const available = props.rows.filter((row) => !props.reason(row.node))
|
|
32
|
+
return (
|
|
33
|
+
available.find((row) => row.node.path === props.focusPath)?.node.path ?? available[0]?.node.path
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
function focusRow(path: string) {
|
|
37
|
+
nextTick(() => {
|
|
38
|
+
const target = [...(list.value?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? [])].find(
|
|
39
|
+
(el) => el.dataset.path === path,
|
|
40
|
+
)
|
|
41
|
+
target?.focus()
|
|
42
|
+
target?.scrollIntoView({ block: 'nearest' })
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
function keydown(event: KeyboardEvent, row: PickerTreeRow) {
|
|
46
|
+
if (props.reason(row.node)) return
|
|
47
|
+
const available = props.rows.filter((item) => !props.reason(item.node))
|
|
48
|
+
const index = available.findIndex((item) => item.node.path === row.node.path)
|
|
49
|
+
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
const target =
|
|
52
|
+
event.key === 'Home'
|
|
53
|
+
? available[0]
|
|
54
|
+
: event.key === 'End'
|
|
55
|
+
? available[available.length - 1]
|
|
56
|
+
: available[
|
|
57
|
+
Math.max(
|
|
58
|
+
0,
|
|
59
|
+
Math.min(available.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)),
|
|
60
|
+
)
|
|
61
|
+
]
|
|
62
|
+
if (target) {
|
|
63
|
+
emit('select', target.node, event)
|
|
64
|
+
focusRow(target.node.path)
|
|
65
|
+
}
|
|
66
|
+
} else if (event.key === 'ArrowRight') {
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
if (row.node.kind === 'folder' && !row.expanded) emit('expand', row.node)
|
|
69
|
+
else if (row.expanded) {
|
|
70
|
+
const next = available[index + 1]
|
|
71
|
+
if (next && next.depth > row.depth) {
|
|
72
|
+
emit('select', next.node, event)
|
|
73
|
+
focusRow(next.node.path)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else if (event.key === 'ArrowLeft') {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
if (row.expanded) emit('expand', row.node)
|
|
79
|
+
else {
|
|
80
|
+
const parent = props.rows
|
|
81
|
+
.slice(0, props.rows.indexOf(row))
|
|
82
|
+
.reverse()
|
|
83
|
+
.find((item) => item.depth < row.depth)
|
|
84
|
+
if (parent) {
|
|
85
|
+
emit('select', parent.node, event)
|
|
86
|
+
focusRow(parent.node.path)
|
|
87
|
+
} else emit('up')
|
|
88
|
+
}
|
|
89
|
+
} else if (event.key === 'Enter') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
emit('enter', row.node)
|
|
92
|
+
} else if (event.key === ' ') {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
emit('select', row.node, event)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function parts(name: string) {
|
|
98
|
+
const index = props.query ? name.toLowerCase().indexOf(props.query.toLowerCase()) : -1
|
|
99
|
+
return index < 0
|
|
100
|
+
? [name, '', '']
|
|
101
|
+
: [
|
|
102
|
+
name.slice(0, index),
|
|
103
|
+
name.slice(index, index + props.query.length),
|
|
104
|
+
name.slice(index + props.query.length),
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<div
|
|
111
|
+
ref="list"
|
|
112
|
+
class="mint-picker__tree"
|
|
113
|
+
role="tree"
|
|
114
|
+
aria-label="Folders and files"
|
|
115
|
+
:aria-multiselectable="mode === 'multi-file' || mode === 'folder+files'"
|
|
116
|
+
:aria-busy="loading"
|
|
117
|
+
>
|
|
118
|
+
<div v-if="loading" class="mint-picker__skeleton">
|
|
119
|
+
<Skeleton v-for="n in 6" :key="n" width="100%" height="36px" />
|
|
120
|
+
</div>
|
|
121
|
+
<div v-else-if="error" role="alert" class="mint-picker__empty">
|
|
122
|
+
<span>{{ error }}</span
|
|
123
|
+
><BaseButton variant="secondary" size="sm" @click="emit('retry')">Try again</BaseButton>
|
|
124
|
+
</div>
|
|
125
|
+
<div v-else-if="!rows.length" class="mint-picker__empty" role="status">
|
|
126
|
+
<strong>{{
|
|
127
|
+
query ? 'No matching folders or files' : emptyTitle || 'No readable files'
|
|
128
|
+
}}</strong>
|
|
129
|
+
<span>{{
|
|
130
|
+
query
|
|
131
|
+
? 'Try a different search or include subfolders.'
|
|
132
|
+
: 'Choose a location, go up a level, or choose local files.'
|
|
133
|
+
}}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<template v-else v-for="row in rows" :key="row.node.path">
|
|
136
|
+
<div
|
|
137
|
+
role="treeitem"
|
|
138
|
+
:data-path="row.node.path"
|
|
139
|
+
:aria-level="row.depth + 1"
|
|
140
|
+
:aria-selected="selected(row.node)"
|
|
141
|
+
:aria-disabled="!!reason(row.node)"
|
|
142
|
+
:aria-expanded="row.node.kind === 'folder' && !reason(row.node) ? row.expanded : undefined"
|
|
143
|
+
:tabindex="!reason(row.node) && row.node.path === focusablePath ? 0 : -1"
|
|
144
|
+
class="mint-picker__row"
|
|
145
|
+
:class="{
|
|
146
|
+
'mint-picker__row--selected': selected(row.node),
|
|
147
|
+
'mint-picker__row--unreadable': !!reason(row.node),
|
|
148
|
+
'mint-picker__row--child': row.depth > 0,
|
|
149
|
+
}"
|
|
150
|
+
:style="{ '--picker-depth': row.depth }"
|
|
151
|
+
:title="reason(row.node) || row.node.name"
|
|
152
|
+
@click="!reason(row.node) && emit('select', row.node, $event)"
|
|
153
|
+
@dblclick="!reason(row.node) && emit('enter', row.node)"
|
|
154
|
+
@keydown="keydown($event, row)"
|
|
155
|
+
>
|
|
156
|
+
<IconButton
|
|
157
|
+
v-if="row.node.kind === 'folder' && row.node.hasChildren !== false && !reason(row.node)"
|
|
158
|
+
size="sm"
|
|
159
|
+
class="mint-picker__disclosure"
|
|
160
|
+
:label="`${row.expanded ? 'Collapse' : 'Expand'} ${row.node.name}`"
|
|
161
|
+
tabindex="-1"
|
|
162
|
+
:loading="row.loading"
|
|
163
|
+
@click.stop="emit('expand', row.node)"
|
|
164
|
+
@dblclick.stop
|
|
165
|
+
>
|
|
166
|
+
<svg
|
|
167
|
+
viewBox="0 0 16 16"
|
|
168
|
+
fill="currentColor"
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
:class="{ 'mint-picker__chevron--open': row.expanded }"
|
|
171
|
+
>
|
|
172
|
+
<path d="m6 3 5 5-5 5z" />
|
|
173
|
+
</svg>
|
|
174
|
+
</IconButton>
|
|
175
|
+
<span v-else class="mint-picker__disclosure-spacer" />
|
|
176
|
+
<svg
|
|
177
|
+
class="mint-picker__node-icon"
|
|
178
|
+
viewBox="0 0 24 24"
|
|
179
|
+
fill="none"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
stroke-width="1.7"
|
|
182
|
+
aria-hidden="true"
|
|
183
|
+
>
|
|
184
|
+
<path :d="row.node.kind === 'folder' ? ICON.folder : ICON.file" />
|
|
185
|
+
</svg>
|
|
186
|
+
<span class="mint-picker__row-name">
|
|
187
|
+
<span class="mint-picker__name"
|
|
188
|
+
>{{ parts(row.node.name)[0]
|
|
189
|
+
}}<mark v-if="parts(row.node.name)[1]">{{ parts(row.node.name)[1] }}</mark
|
|
190
|
+
>{{ parts(row.node.name)[2] }}</span
|
|
191
|
+
>
|
|
192
|
+
<span v-if="row.node.kind === 'folder' && row.node.formats" class="mint-picker__formats">
|
|
193
|
+
<span v-for="(count, format) in row.node.formats" :key="format"
|
|
194
|
+
>{{ format.toUpperCase() }} ×{{ count }}</span
|
|
195
|
+
>
|
|
196
|
+
</span>
|
|
197
|
+
<span v-else-if="query && row.node.parentName" class="mint-picker__parent-name">{{
|
|
198
|
+
row.node.parentName
|
|
199
|
+
}}</span>
|
|
200
|
+
</span>
|
|
201
|
+
<span class="mint-picker__row-meta">
|
|
202
|
+
<span
|
|
203
|
+
v-if="row.node.kind === 'folder' && progress(row.node)"
|
|
204
|
+
:class="{
|
|
205
|
+
'mint-picker__partial':
|
|
206
|
+
selected(row.node) &&
|
|
207
|
+
progress(row.node).split(' / ')[0] !== String(row.node.fileCount),
|
|
208
|
+
}"
|
|
209
|
+
>{{ progress(row.node) }}</span
|
|
210
|
+
>
|
|
211
|
+
<span v-if="!reason(row.node) && row.node.bytes != null">{{
|
|
212
|
+
formatBytes(row.node.bytes)
|
|
213
|
+
}}</span>
|
|
214
|
+
<span v-if="reason(row.node)" aria-label="Unavailable">ⓘ</span>
|
|
215
|
+
</span>
|
|
216
|
+
<BaseButton
|
|
217
|
+
v-if="
|
|
218
|
+
query &&
|
|
219
|
+
mode === 'folder' &&
|
|
220
|
+
row.node.kind === 'file' &&
|
|
221
|
+
row.node.parentPath &&
|
|
222
|
+
!reason(row.node)
|
|
223
|
+
"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
size="sm"
|
|
226
|
+
@click.stop="emit('parent', row.node)"
|
|
227
|
+
>Select its folder</BaseButton
|
|
228
|
+
>
|
|
229
|
+
</div>
|
|
230
|
+
<div v-if="row.error" class="mint-picker__branch-error" role="alert">
|
|
231
|
+
{{ row.error
|
|
232
|
+
}}<BaseButton variant="ghost" size="sm" @click="emit('expand', row.node)"
|
|
233
|
+
>Close branch</BaseButton
|
|
234
|
+
>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
237
|
+
</div>
|
|
238
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PickerAdapter, PickerFile, PickerNode } from '../../types/filePicker'
|
|
2
|
+
import { pickerFormat } from './validation'
|
|
3
|
+
|
|
4
|
+
/** Construct metadata only: no FileReader, arrayBuffer or upload. */
|
|
5
|
+
export function localFilesAdapter(
|
|
6
|
+
files: File[],
|
|
7
|
+
relativePaths?: (string | undefined)[],
|
|
8
|
+
): PickerAdapter {
|
|
9
|
+
const nodes = new Map<string, PickerNode>()
|
|
10
|
+
for (const [index, file] of files.entries()) {
|
|
11
|
+
const relative = relativePaths?.[index] || file.webkitRelativePath || file.name
|
|
12
|
+
const parts = relative.split('/')
|
|
13
|
+
let parent: string | null = null
|
|
14
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
15
|
+
const path = parts.slice(0, i + 1).join('/')
|
|
16
|
+
if (!nodes.has(path))
|
|
17
|
+
nodes.set(path, { kind: 'folder', name: parts[i]!, path, parentPath: parent })
|
|
18
|
+
parent = path
|
|
19
|
+
}
|
|
20
|
+
// Plain file picks can have equal names; preserve both File objects.
|
|
21
|
+
let path = relative
|
|
22
|
+
for (let n = 2; nodes.has(path); n++) path = `${relative} (${n})`
|
|
23
|
+
nodes.set(path, {
|
|
24
|
+
kind: 'file',
|
|
25
|
+
name: file.name,
|
|
26
|
+
path,
|
|
27
|
+
parentPath: parent,
|
|
28
|
+
bytes: file.size,
|
|
29
|
+
format: pickerFormat(file.name),
|
|
30
|
+
file,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
for (const node of nodes.values()) {
|
|
34
|
+
if (node.kind !== 'folder') continue
|
|
35
|
+
const descendants = [...nodes.values()].filter(
|
|
36
|
+
(item): item is PickerFile => item.kind === 'file' && item.path.startsWith(`${node.path}/`),
|
|
37
|
+
)
|
|
38
|
+
node.fileCount = descendants.length
|
|
39
|
+
node.bytes = descendants.reduce((sum, file) => sum + (file.bytes ?? 0), 0)
|
|
40
|
+
node.formats = {}
|
|
41
|
+
descendants.forEach((file) => {
|
|
42
|
+
node.formats![file.format] = (node.formats![file.format] ?? 0) + 1
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
const list = (path: string) =>
|
|
46
|
+
[...nodes.values()].filter((node) => (node.parentPath ?? '') === path)
|
|
47
|
+
return {
|
|
48
|
+
listRoot: async () => list(''),
|
|
49
|
+
listChildren: async (path) => list(path),
|
|
50
|
+
search: async (query, scope) =>
|
|
51
|
+
[...nodes.values()].filter((node) => {
|
|
52
|
+
const inScope = scope.recursive
|
|
53
|
+
? !scope.path || node.path.startsWith(`${scope.path}/`)
|
|
54
|
+
: (node.parentPath ?? '') === scope.path
|
|
55
|
+
return inScope && node.name.toLowerCase().includes(query.toLowerCase())
|
|
56
|
+
}),
|
|
57
|
+
}
|
|
58
|
+
}
|