@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.1

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 (215) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
  3. package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DHE9k8TP.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DOcCf0Vo.js} +41 -18
  7. package/dist/SettingsModal-DOcCf0Vo.js.map +1 -0
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-jhF9wUkU.js} +19 -9
  9. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  12. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  13. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  14. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  15. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  16. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  17. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  18. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  20. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  21. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  22. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  23. package/dist/apiErrors.d.ts +26 -0
  24. package/dist/components/AppSidebar.vue.d.ts +7 -7
  25. package/dist/components/BaseInput.vue.d.ts +1 -1
  26. package/dist/components/BasePill.vue.d.ts +3 -0
  27. package/dist/components/BaseSelect.vue.d.ts +1 -1
  28. package/dist/components/BaseTabs.vue.d.ts +1 -4
  29. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  30. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  31. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  32. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  33. package/dist/components/DatePicker.vue.d.ts +1 -1
  34. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  35. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  36. package/dist/components/FilePicker.vue.d.ts +32 -0
  37. package/dist/components/FileUploader.vue.d.ts +8 -2
  38. package/dist/components/FormField.vue.d.ts +8 -1
  39. package/dist/components/FormulaInput.vue.d.ts +1 -1
  40. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  41. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  42. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  43. package/dist/components/NumberInput.vue.d.ts +1 -1
  44. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  45. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  46. package/dist/components/ResourceCard.vue.d.ts +1 -1
  47. package/dist/components/SecretInput.vue.d.ts +1 -1
  48. package/dist/components/SequenceInput.vue.d.ts +1 -1
  49. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  50. package/dist/components/TagsInput.vue.d.ts +1 -1
  51. package/dist/components/TimePicker.vue.d.ts +1 -1
  52. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  53. package/dist/components/UnitInput.vue.d.ts +1 -1
  54. package/dist/components/WellPlate.vue.d.ts +1 -1
  55. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  56. package/dist/components/index.d.ts +4 -0
  57. package/dist/components/index.js +8 -8
  58. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  59. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  60. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  61. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  62. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  63. package/dist/{components-CgAUzF8F.js → components-Cp8DcNrU.js} +4363 -1730
  64. package/dist/components-Cp8DcNrU.js.map +1 -0
  65. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  66. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  67. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  68. package/dist/composables/filePicker/validation.d.ts +5 -0
  69. package/dist/composables/index.d.ts +2 -0
  70. package/dist/composables/index.js +10 -10
  71. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  72. package/dist/composables/useApi.d.ts +14 -7
  73. package/dist/composables/useFileBrowser.d.ts +55 -0
  74. package/dist/composables/useFilePicker.d.ts +189 -0
  75. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  76. package/dist/composables/useToast.d.ts +22 -1
  77. package/dist/{composables-B_pia3hO.js → composables-D_4qqOlp.js} +300 -11
  78. package/dist/composables-D_4qqOlp.js.map +1 -0
  79. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  80. package/dist/errors-BLuqMee5.js.map +1 -0
  81. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  82. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  83. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  84. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  85. package/dist/index.d.ts +1 -0
  86. package/dist/index.js +15 -15
  87. package/dist/install.js +2 -2
  88. package/dist/stores/index.js +2 -2
  89. package/dist/styles.css +2957 -1708
  90. package/dist/templates/index.js +2 -2
  91. package/dist/{templates-Qq113pyZ.js → templates-Dd9bBSs6.js} +2 -2
  92. package/dist/{templates-Qq113pyZ.js.map → templates-Dd9bBSs6.js.map} +1 -1
  93. package/dist/types/components.d.ts +21 -0
  94. package/dist/types/fileBrowserTypes.d.ts +77 -0
  95. package/dist/types/filePicker.d.ts +77 -0
  96. package/dist/types/form-builder.d.ts +2 -0
  97. package/dist/types/generated-ui.d.ts +2 -0
  98. package/dist/types/index.d.ts +3 -1
  99. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BB2GRPhW.js} +190 -105
  100. package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
  101. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  102. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  103. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-G_CAOFTX.js} +4 -4
  104. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  105. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-BnGSdSRG.js} +2 -2
  106. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
  107. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-CTxuzLIX.js} +70 -13
  108. package/dist/validation-CTxuzLIX.js.map +1 -0
  109. package/package.json +1 -1
  110. package/src/__tests__/apiErrors.test.ts +81 -0
  111. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  112. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  113. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  114. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  115. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  116. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  117. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  118. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  119. package/src/__tests__/components/FilePicker.test.ts +487 -0
  120. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  121. package/src/__tests__/components/FormField.test.ts +26 -0
  122. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  123. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  124. package/src/__tests__/components/NumberInput.test.ts +105 -0
  125. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  126. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  127. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  128. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  129. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  130. package/src/__tests__/composables/localFiles.test.ts +57 -0
  131. package/src/__tests__/composables/useApi.test.ts +92 -9
  132. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  133. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  134. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  135. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  136. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  137. package/src/__tests__/generatedUi.test.ts +111 -0
  138. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  139. package/src/apiErrors.ts +85 -0
  140. package/src/components/AppLayout.story.vue +1 -1
  141. package/src/components/AppSidebar.story.vue +7 -10
  142. package/src/components/AppSidebar.vue +178 -58
  143. package/src/components/AppToastContainer.vue +65 -23
  144. package/src/components/BasePill.story.vue +2 -0
  145. package/src/components/BasePill.vue +4 -0
  146. package/src/components/BaseTabs.story.vue +0 -42
  147. package/src/components/BaseTabs.vue +3 -6
  148. package/src/components/ExperimentPopover.vue +10 -2
  149. package/src/components/FileBrowserModal.story.vue +203 -0
  150. package/src/components/FileBrowserModal.vue +384 -0
  151. package/src/components/FilePicker.story.vue +44 -0
  152. package/src/components/FilePicker.vue +284 -0
  153. package/src/components/FileUploader.vue +42 -15
  154. package/src/components/FormField.vue +10 -3
  155. package/src/components/GeneratedPathInput.vue +11 -7
  156. package/src/components/GeneratedPlotlyResult.vue +34 -69
  157. package/src/components/GeneratedResultRenderer.vue +0 -5
  158. package/src/components/NumberInput.story.vue +32 -13
  159. package/src/components/NumberInput.vue +116 -55
  160. package/src/components/PlotlyChart.story.vue +61 -0
  161. package/src/components/PlotlyChart.vue +233 -0
  162. package/src/components/PluginWorkspaceView.props.ts +3 -3
  163. package/src/components/SegmentedControl.vue +2 -2
  164. package/src/components/fileBrowserPresentation.ts +39 -0
  165. package/src/components/index.ts +5 -0
  166. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  167. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  168. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  169. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  170. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  171. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  172. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  173. package/src/composables/filePicker/localFiles.ts +65 -0
  174. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  175. package/src/composables/filePicker/usePickerSelection.ts +270 -0
  176. package/src/composables/filePicker/validation.ts +41 -0
  177. package/src/composables/index.ts +7 -0
  178. package/src/composables/pluginWorkspaceModel.ts +0 -3
  179. package/src/composables/useApi.ts +57 -13
  180. package/src/composables/useFileBrowser.ts +314 -0
  181. package/src/composables/useFilePicker.ts +366 -0
  182. package/src/composables/useGeneratedAnalysis.ts +10 -17
  183. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  184. package/src/composables/usePluginClient.ts +3 -0
  185. package/src/composables/usePluginWorkspace.ts +1 -2
  186. package/src/composables/useToast.ts +21 -5
  187. package/src/generated/schema.ts +58 -18
  188. package/src/index.ts +3 -0
  189. package/src/styles/components/app-sidebar.css +282 -121
  190. package/src/styles/components/file-browser-modal.css +489 -0
  191. package/src/styles/components/file-picker.css +502 -0
  192. package/src/styles/components/form-builder.css +1 -1
  193. package/src/styles/components/form-field.css +28 -0
  194. package/src/styles/components/icon-button.css +9 -0
  195. package/src/styles/components/number-input.css +73 -91
  196. package/src/styles/components/pill.css +49 -51
  197. package/src/styles/components/segmented-control.css +16 -11
  198. package/src/styles/components/tabs.css +18 -34
  199. package/src/styles/components/toast.css +136 -27
  200. package/src/styles/index.css +3 -0
  201. package/src/styles/variables.css +25 -5
  202. package/src/types/components.ts +23 -0
  203. package/src/types/fileBrowserTypes.ts +85 -0
  204. package/src/types/filePicker.ts +72 -0
  205. package/src/types/form-builder.ts +2 -0
  206. package/src/types/generated-ui.ts +2 -0
  207. package/src/types/index.ts +16 -0
  208. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  209. package/dist/SettingsModal-Dphh3RrI.js.map +0 -1
  210. package/dist/Skeleton-DxtwJ5gt.js.map +0 -1
  211. package/dist/components-CgAUzF8F.js.map +0 -1
  212. package/dist/composables-B_pia3hO.js.map +0 -1
  213. package/dist/errors-_W0-LbA6.js.map +0 -1
  214. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  215. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -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>
@@ -20,6 +20,23 @@ const isChoiceField = computed(() =>
20
20
  )
21
21
 
22
22
  const componentProps = computed<Record<string, unknown>>(() => {
23
+ if (props.field.type === 'multiselect') {
24
+ const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
25
+ return { ...props.resolvedProps, modelValue: values }
26
+ }
27
+ if (props.field.type === 'tags') {
28
+ const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
29
+ const onUpdate = props.resolvedProps['onUpdate:modelValue'] as ((value: unknown) => void) | undefined
30
+ // TagsInput edits strings; numeric fields are parsed back on the way out so
31
+ // nullable or numeric form data is displayed without being replaced.
32
+ return {
33
+ ...props.resolvedProps,
34
+ modelValue: values.map(String),
35
+ 'onUpdate:modelValue': (next: unknown) => onUpdate?.(
36
+ props.field.itemType === 'number' && Array.isArray(next) ? next.map(Number) : next,
37
+ ),
38
+ }
39
+ }
23
40
  if (!isChoiceField.value) return props.resolvedProps
24
41
 
25
42
  return {
@@ -34,11 +34,10 @@ defineSlots<{
34
34
  :title="sidebar.title"
35
35
  :subtitle="sidebar.subtitle"
36
36
  :badge="sidebar.badge"
37
- :variant="sidebar.variant"
38
37
  :panels="sidebar.panels"
39
38
  :active-view="sidebar.activeView"
40
39
  :floating="sidebar.floating"
41
- :dense="sidebar.dense"
40
+ :density="sidebar.dense ? 'compact' : 'comfortable'"
42
41
  :width="sidebar.width"
43
42
  :side="sidebar.side"
44
43
  :forms="sidebar.forms"
@@ -0,0 +1,65 @@
1
+ import type { PickerAdapter, 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, {
18
+ kind: 'folder', name: parts[i]!, path, parentPath: parent,
19
+ fileCount: 0, bytes: 0, formats: {},
20
+ })
21
+ parent = path
22
+ }
23
+ // Plain file picks can have equal names; preserve both File objects.
24
+ let path = relative
25
+ for (let n = 2; nodes.has(path); n++) path = `${relative} (${n})`
26
+ nodes.set(path, {
27
+ kind: 'file',
28
+ name: file.name,
29
+ path,
30
+ parentPath: parent,
31
+ bytes: file.size,
32
+ format: pickerFormat(file.name),
33
+ file,
34
+ })
35
+ }
36
+ const children = new Map<string, PickerNode[]>()
37
+ for (const node of nodes.values()) {
38
+ const parent = node.parentPath ?? ''
39
+ const siblings = children.get(parent)
40
+ if (siblings) siblings.push(node)
41
+ else children.set(parent, [node])
42
+ if (node.kind !== 'file') continue
43
+ // Each file contributes once to its ancestors, rather than scanning all
44
+ // files again for every folder in the selection.
45
+ for (let slash = node.path.indexOf('/'); slash >= 0; slash = node.path.indexOf('/', slash + 1)) {
46
+ const folder = nodes.get(node.path.slice(0, slash))
47
+ if (folder?.kind !== 'folder') continue
48
+ folder.fileCount = (folder.fileCount ?? 0) + 1
49
+ folder.bytes = (folder.bytes ?? 0) + (node.bytes ?? 0)
50
+ folder.formats![node.format] = (folder.formats![node.format] ?? 0) + 1
51
+ }
52
+ }
53
+ const list = (path: string) => [...(children.get(path) ?? [])]
54
+ return {
55
+ listRoot: async () => list(''),
56
+ listChildren: async (path) => list(path),
57
+ search: async (query, scope) =>
58
+ [...nodes.values()].filter((node) => {
59
+ const inScope = scope.recursive
60
+ ? !scope.path || node.path.startsWith(`${scope.path}/`)
61
+ : (node.parentPath ?? '') === scope.path
62
+ return inScope && node.name.toLowerCase().includes(query.toLowerCase())
63
+ }),
64
+ }
65
+ }
@@ -0,0 +1,220 @@
1
+ import { computed, onScopeDispose, ref, shallowRef } from 'vue'
2
+ import type {
3
+ PickerAdapter,
4
+ PickerNode,
5
+ PickerRequest,
6
+ PickerTreeRow,
7
+ } from '../../types/filePicker'
8
+
9
+ export function usePickerNavigation(
10
+ adapter: () => PickerAdapter,
11
+ request: () => PickerRequest,
12
+ systemFilter: (node: PickerNode) => boolean = () => true,
13
+ ) {
14
+ const path = shallowRef('')
15
+ const trail = ref<{ path: string; label: string }[]>([])
16
+ const entries = shallowRef<PickerNode[]>([])
17
+ const nodes = ref(new Map<string, PickerNode>())
18
+ const children = shallowRef(new Map<string, PickerNode[]>())
19
+ const expanded = ref(new Set<string>())
20
+ const expanding = ref(new Set<string>())
21
+ const expansionErrors = ref(new Map<string, string>())
22
+ const loading = shallowRef(false)
23
+ const error = shallowRef('')
24
+ let epoch = 0
25
+ let navigation = 0
26
+ let controller: AbortController | undefined
27
+ const pending = new Map<string, Promise<PickerNode[]>>()
28
+ const childControllers = new Set<AbortController>()
29
+
30
+ function isAllowed(node: PickerNode): boolean {
31
+ const seen = new Set<string>()
32
+ let current: PickerNode | undefined = node
33
+ while (current && !seen.has(current.path)) {
34
+ if (!systemFilter(current)) return false
35
+ seen.add(current.path)
36
+ current = current.parentPath ? nodes.value.get(current.parentPath) : undefined
37
+ }
38
+ return true
39
+ }
40
+
41
+ function register(items: PickerNode[], parent?: string) {
42
+ for (const item of items)
43
+ nodes.value.set(item.path, {
44
+ ...item,
45
+ parentPath:
46
+ item.parentPath !== undefined
47
+ ? item.parentPath
48
+ : parent !== undefined
49
+ ? parent || null
50
+ : (nodes.value.get(item.path)?.parentPath ?? null),
51
+ })
52
+ }
53
+ function invalidate() {
54
+ ++epoch
55
+ ++navigation
56
+ controller?.abort()
57
+ childControllers.forEach((item) => item.abort())
58
+ childControllers.clear()
59
+ pending.clear()
60
+ children.value = new Map()
61
+ expanding.value.clear()
62
+ expansionErrors.value.clear()
63
+ }
64
+ function clear() {
65
+ invalidate()
66
+ nodes.value.clear()
67
+ expanded.value.clear()
68
+ expanding.value.clear()
69
+ expansionErrors.value.clear()
70
+ entries.value = []
71
+ loading.value = false
72
+ error.value = ''
73
+ }
74
+ async function loadChildren(
75
+ folder: string,
76
+ ancestors: string[] = [],
77
+ refresh = false,
78
+ ): Promise<PickerNode[]> {
79
+ if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.')
80
+ const cached = children.value.get(folder)
81
+ if (cached) return cached
82
+ const existing = pending.get(folder)
83
+ if (existing) return existing
84
+ const token = epoch
85
+ const abort = new AbortController()
86
+ childControllers.add(abort)
87
+ const task = adapter()
88
+ .listChildren(folder, { ...request(), ...(refresh && { refresh }), signal: abort.signal })
89
+ .then((items) => {
90
+ if (token !== epoch) throw new DOMException('Cancelled', 'AbortError')
91
+ const retained = new Set(items.map((item) => item.path))
92
+ function remove(path: string, seen = new Set<string>()) {
93
+ if (seen.has(path) || path === folder) return
94
+ seen.add(path)
95
+ for (const node of nodes.value.values())
96
+ if (node.parentPath === path) remove(node.path, seen)
97
+ nodes.value.delete(path)
98
+ children.value.delete(path)
99
+ }
100
+ for (const node of nodes.value.values())
101
+ if (node.parentPath === folder && !retained.has(node.path)) remove(node.path)
102
+ register(items, folder)
103
+ children.value = new Map(children.value).set(folder, items)
104
+ return items
105
+ })
106
+ .finally(() => {
107
+ childControllers.delete(abort)
108
+ if (token === epoch) pending.delete(folder)
109
+ })
110
+ pending.set(folder, task)
111
+ return task
112
+ }
113
+ async function load(query = '', recursive = false, refresh = false) {
114
+ const token = ++navigation
115
+ controller?.abort()
116
+ controller = new AbortController()
117
+ const options = { ...request(), ...(refresh && { refresh }), signal: controller.signal }
118
+ loading.value = true
119
+ error.value = ''
120
+ entries.value = []
121
+ try {
122
+ const items = query.trim()
123
+ ? await adapter().search(query.trim(), { path: path.value, recursive }, options)
124
+ : path.value
125
+ ? await loadChildren(path.value, [], refresh)
126
+ : await adapter().listRoot(options)
127
+ if (token !== navigation) return
128
+ register(items, query.trim() ? undefined : path.value)
129
+ entries.value = items
130
+ } catch (cause) {
131
+ if (token === navigation)
132
+ error.value = cause instanceof Error ? cause.message : 'Could not read this location.'
133
+ } finally {
134
+ if (token === navigation) loading.value = false
135
+ }
136
+ }
137
+ async function expand(node: PickerNode) {
138
+ if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
139
+ if (expanded.value.has(node.path)) {
140
+ expanded.value.delete(node.path)
141
+ return
142
+ }
143
+ expanded.value.add(node.path)
144
+ expanding.value.add(node.path)
145
+ expansionErrors.value.delete(node.path)
146
+ const token = epoch
147
+ try {
148
+ await loadChildren(node.path)
149
+ } catch (cause) {
150
+ if (token === epoch)
151
+ expansionErrors.value.set(
152
+ node.path,
153
+ cause instanceof Error ? cause.message : 'Could not read this folder.',
154
+ )
155
+ } finally {
156
+ if (token === epoch) expanding.value.delete(node.path)
157
+ }
158
+ }
159
+ function enter(node: PickerNode) {
160
+ if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
161
+ const index = trail.value.findIndex((crumb) => crumb.path === node.path)
162
+ trail.value =
163
+ index >= 0
164
+ ? trail.value.slice(0, index + 1)
165
+ : [...trail.value, { path: node.path, label: node.name }]
166
+ path.value = node.path
167
+ }
168
+ function up(target?: string) {
169
+ const index =
170
+ target !== undefined
171
+ ? trail.value.findIndex((crumb) => crumb.path === target)
172
+ : trail.value.length - 2
173
+ trail.value = index < 0 ? [] : trail.value.slice(0, index + 1)
174
+ path.value = trail.value[trail.value.length - 1]?.path ?? ''
175
+ }
176
+ const rows = computed(() => {
177
+ const result: PickerTreeRow[] = []
178
+ function visit(items: PickerNode[], depth: number, ancestors: Set<string>) {
179
+ for (const item of items) {
180
+ if (ancestors.has(item.path) || depth > 30) continue
181
+ const node = nodes.value.get(item.path) ?? item
182
+ if (!isAllowed(node)) continue
183
+ result.push({
184
+ node,
185
+ depth,
186
+ expanded: expanded.value.has(item.path),
187
+ loading: expanding.value.has(item.path),
188
+ error: expansionErrors.value.get(item.path),
189
+ })
190
+ if (expanded.value.has(item.path))
191
+ visit(children.value.get(item.path) ?? [], depth + 1, new Set([...ancestors, item.path]))
192
+ }
193
+ }
194
+ visit(entries.value, 0, new Set())
195
+ return result
196
+ })
197
+ onScopeDispose(clear)
198
+ return {
199
+ path,
200
+ trail,
201
+ entries,
202
+ nodes,
203
+ children,
204
+ expanded,
205
+ expanding,
206
+ expansionErrors,
207
+ loading,
208
+ error,
209
+ rows,
210
+ isAllowed,
211
+ clear,
212
+ invalidate,
213
+ register,
214
+ loadChildren,
215
+ load,
216
+ expand,
217
+ enter,
218
+ up,
219
+ }
220
+ }