@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.
Files changed (152) 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-DCXmwKNS.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
  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/PlotlyChart.test.d.ts +1 -0
  15. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  17. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  18. package/dist/apiErrors.d.ts +26 -0
  19. package/dist/components/BaseInput.vue.d.ts +1 -1
  20. package/dist/components/BaseSelect.vue.d.ts +1 -1
  21. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  22. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  23. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  25. package/dist/components/DatePicker.vue.d.ts +1 -1
  26. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  27. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  28. package/dist/components/FilePicker.vue.d.ts +32 -0
  29. package/dist/components/FileUploader.vue.d.ts +5 -2
  30. package/dist/components/FormulaInput.vue.d.ts +1 -1
  31. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  32. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  33. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  34. package/dist/components/NumberInput.vue.d.ts +1 -1
  35. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  36. package/dist/components/ResourceCard.vue.d.ts +1 -1
  37. package/dist/components/SecretInput.vue.d.ts +1 -1
  38. package/dist/components/SequenceInput.vue.d.ts +1 -1
  39. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  40. package/dist/components/TagsInput.vue.d.ts +1 -1
  41. package/dist/components/TimePicker.vue.d.ts +1 -1
  42. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  43. package/dist/components/UnitInput.vue.d.ts +1 -1
  44. package/dist/components/WellPlate.vue.d.ts +1 -1
  45. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  46. package/dist/components/index.d.ts +4 -0
  47. package/dist/components/index.js +8 -8
  48. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  49. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  50. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  51. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  52. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  53. package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
  54. package/dist/components-DIum5hkx.js.map +1 -0
  55. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  56. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  57. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  58. package/dist/composables/filePicker/validation.d.ts +5 -0
  59. package/dist/composables/index.d.ts +2 -0
  60. package/dist/composables/index.js +10 -10
  61. package/dist/composables/useApi.d.ts +14 -7
  62. package/dist/composables/useFileBrowser.d.ts +55 -0
  63. package/dist/composables/useFilePicker.d.ts +189 -0
  64. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  65. package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
  66. package/dist/composables-mFXsTA4g.js.map +1 -0
  67. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  68. package/dist/errors-BLuqMee5.js.map +1 -0
  69. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  70. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  71. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  72. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.js +15 -15
  75. package/dist/install.js +2 -2
  76. package/dist/stores/index.js +2 -2
  77. package/dist/styles.css +2369 -1377
  78. package/dist/templates/index.js +2 -2
  79. package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
  80. package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
  81. package/dist/types/fileBrowserTypes.d.ts +77 -0
  82. package/dist/types/filePicker.d.ts +77 -0
  83. package/dist/types/index.d.ts +2 -0
  84. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
  85. package/dist/useControlSchema-DldpxNOP.js.map +1 -0
  86. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  87. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  88. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
  89. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
  90. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
  91. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
  92. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
  93. package/dist/validation-uwoJGEb6.js.map +1 -0
  94. package/package.json +1 -1
  95. package/src/__tests__/apiErrors.test.ts +81 -0
  96. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  97. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  98. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  99. package/src/__tests__/components/FilePicker.test.ts +487 -0
  100. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  101. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  102. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  103. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  104. package/src/__tests__/composables/useApi.test.ts +92 -9
  105. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  106. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  107. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  108. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  109. package/src/apiErrors.ts +85 -0
  110. package/src/components/ExperimentPopover.vue +10 -2
  111. package/src/components/FileBrowserModal.story.vue +203 -0
  112. package/src/components/FileBrowserModal.vue +384 -0
  113. package/src/components/FilePicker.story.vue +44 -0
  114. package/src/components/FilePicker.vue +284 -0
  115. package/src/components/FileUploader.vue +32 -14
  116. package/src/components/GeneratedPlotlyResult.vue +34 -69
  117. package/src/components/GeneratedResultRenderer.vue +0 -5
  118. package/src/components/PlotlyChart.story.vue +61 -0
  119. package/src/components/PlotlyChart.vue +233 -0
  120. package/src/components/SegmentedControl.vue +2 -2
  121. package/src/components/fileBrowserPresentation.ts +39 -0
  122. package/src/components/index.ts +5 -0
  123. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  124. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  125. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  126. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  127. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  128. package/src/composables/filePicker/localFiles.ts +58 -0
  129. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  130. package/src/composables/filePicker/usePickerSelection.ts +259 -0
  131. package/src/composables/filePicker/validation.ts +41 -0
  132. package/src/composables/index.ts +7 -0
  133. package/src/composables/useApi.ts +57 -13
  134. package/src/composables/useFileBrowser.ts +314 -0
  135. package/src/composables/useFilePicker.ts +366 -0
  136. package/src/composables/useGeneratedAnalysis.ts +10 -17
  137. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  138. package/src/composables/usePluginClient.ts +3 -0
  139. package/src/index.ts +3 -0
  140. package/src/styles/components/file-browser-modal.css +489 -0
  141. package/src/styles/components/file-picker.css +502 -0
  142. package/src/styles/components/icon-button.css +9 -0
  143. package/src/styles/index.css +3 -0
  144. package/src/types/fileBrowserTypes.ts +85 -0
  145. package/src/types/filePicker.ts +72 -0
  146. package/src/types/index.ts +14 -0
  147. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  148. package/dist/components-CgAUzF8F.js.map +0 -1
  149. package/dist/composables-B_pia3hO.js.map +0 -1
  150. package/dist/errors-_W0-LbA6.js.map +0 -1
  151. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  152. 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
+ }