@morscherlab/mint-sdk 1.3.0-beta.9 → 1.3.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 (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/SmartGroup.types.d.ts +22 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  9. package/dist/components/index.js +1 -1
  10. package/dist/{components-GDDvof8D.js → components-bbanLd1u.js} +510 -287
  11. package/dist/components-bbanLd1u.js.map +1 -0
  12. package/dist/composables/autoGroup/classify.d.ts +7 -1
  13. package/dist/composables/autoGroup/columns.d.ts +16 -1
  14. package/dist/composables/autoGroup/index.d.ts +5 -7
  15. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  16. package/dist/composables/index.js +2 -2
  17. package/dist/composables/useAutoGroup.d.ts +13 -2
  18. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  19. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  20. package/dist/index.js +3 -3
  21. package/dist/install.js +1 -1
  22. package/dist/permissions.js +2 -2
  23. package/dist/permissions.js.map +1 -1
  24. package/dist/styles.css +129 -96
  25. package/dist/types/auto-group.d.ts +1 -1
  26. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  27. package/dist/validation-CukfLjy-.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  33. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  34. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  35. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  36. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  37. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  38. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  39. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  40. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  41. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  42. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  43. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  44. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  47. package/src/__tests__/utils/permissions.test.ts +15 -0
  48. package/src/components/AutoGroupModal.adapter.ts +80 -9
  49. package/src/components/AutoGroupModal.vue +41 -8
  50. package/src/components/SmartGroup.types.ts +23 -0
  51. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  52. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  53. package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
  54. package/src/composables/autoGroup/classify.ts +26 -7
  55. package/src/composables/autoGroup/columns.ts +144 -36
  56. package/src/composables/autoGroup/compose.ts +8 -10
  57. package/src/composables/autoGroup/fingerprint.ts +1 -1
  58. package/src/composables/autoGroup/index.ts +5 -12
  59. package/src/composables/autoGroup/tokenLength.ts +8 -0
  60. package/src/composables/autoGroup/tokenize.ts +148 -1
  61. package/src/composables/autoGroup/vocab.json +5 -11
  62. package/src/composables/useAutoGroup.ts +92 -79
  63. package/src/permissions.ts +3 -2
  64. package/src/styles/components/app-page-selector.css +58 -14
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-GDDvof8D.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -33,7 +33,7 @@ const DEFAULT_PALETTE = [...SAMPLE_GROUP_TOKENS]
33
33
  </script>
34
34
 
35
35
  <script setup lang="ts">
36
- import { computed, h, ref, useTemplateRef, type FunctionalComponent } from 'vue'
36
+ import { computed, h, nextTick, ref, type FunctionalComponent } from 'vue'
37
37
  import {
38
38
  appendQcGroup,
39
39
  computeFieldGroups,
@@ -43,10 +43,14 @@ import {
43
43
  type QcRoute,
44
44
  } from './SmartGroupFieldRecipe.groups'
45
45
  import { sampleTypeMarker } from '../utils/sampleTypeMarker'
46
- import { useDropdownState } from '../composables/useDropdownState'
46
+ import { useListReorder } from '../composables/useListReorder'
47
+ import ActionMenu from './ActionMenu.vue'
47
48
  import BaseButton from './BaseButton.vue'
49
+ import BaseToggle from './BaseToggle.vue'
48
50
  import LoadingSpinner from './LoadingSpinner.vue'
49
- import type { FieldRecipeApplyPayload, SmartGroupFieldRecipeProps } from './SmartGroup.types'
51
+ import SegmentedControl from './SegmentedControl.vue'
52
+ import type { ActionMenuItem } from '../types/components'
53
+ import type { FieldRecipeApplyPayload, FieldRecipeQcChip, SmartGroupFieldRecipeProps } from './SmartGroup.types'
50
54
 
51
55
  const props = withDefaults(defineProps<SmartGroupFieldRecipeProps>(), {
52
56
  samples: () => DEFAULT_SAMPLES,
@@ -58,6 +62,7 @@ const props = withDefaults(defineProps<SmartGroupFieldRecipeProps>(), {
58
62
  qcColor: 'var(--mint-sample-9)',
59
63
  canDownloadTemplate: true,
60
64
  loading: false,
65
+ renamable: false,
61
66
  })
62
67
 
63
68
  const emit = defineEmits<{
@@ -67,8 +72,10 @@ const emit = defineEmits<{
67
72
  close: []
68
73
  /** Controlled mode: a field toggle was clicked (card index). */
69
74
  toggle: [index: number]
70
- /** Controlled mode: a layer reorder button was clicked (card index). */
75
+ /** A field moved one layer (card index). A drag over several layers emits one event for each step. */
71
76
  move: [index: number, direction: 'up' | 'down']
77
+ /** The user gave a field a new name (card index). */
78
+ rename: [index: number, name: string]
72
79
  /** Controlled mode: the QC routing select changed. */
73
80
  'update:route': [route: QcRoute]
74
81
  /** A CSV/TSV file was chosen (upload button) or dropped; parent parses it. */
@@ -102,9 +109,9 @@ const ICON = {
102
109
  check: 'M20 6L9 17l-5-5',
103
110
  close: 'M6 18L18 6M6 6l12 12',
104
111
  upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
105
- download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
106
112
  chev: 'M6 9l6 6 6-6',
107
- chevUp: 'M18 15l-6-6-6 6',
113
+ grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
114
+ pencil: 'M12 20h9M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z',
108
115
  }
109
116
 
110
117
  const MAX_BAR = 44
@@ -131,7 +138,9 @@ function onFileChange(event: Event) {
131
138
  if (file) emit('csv-file', file)
132
139
  input.value = '' // allow re-selecting the same file
133
140
  }
134
- function onDragEnter() {
141
+ function onDragEnter(event: DragEvent) {
142
+ // A field row dragged to a new layer is not a file drop.
143
+ if (!event.dataTransfer?.types.includes('Files')) return
135
144
  dragDepth += 1
136
145
  dragOver.value = true
137
146
  }
@@ -146,39 +155,35 @@ function onDrop(event: DragEvent) {
146
155
  if (file && isCsvLike(file)) emit('csv-file', file)
147
156
  }
148
157
 
149
- // CSV action menu — reuses the SDK's dropdown state (outside-click / Escape
150
- // close) but renders a compact action menu in the .fr-* styling, since the
151
- // items are actions (upload / download), not a persistent value selection.
152
- const {
153
- isOpen: csvMenuOpen,
154
- close: closeCsvMenu,
155
- toggle: toggleCsvMenu,
156
- } = useDropdownState({
157
- rootRef: useTemplateRef<HTMLElement>('csvMenuRoot'),
158
- triggerRef: useTemplateRef<HTMLElement>('csvMenuTrigger'),
159
- })
160
-
161
- function chooseUpload() {
162
- closeCsvMenu()
163
- pickCsv()
164
- }
165
- function chooseDownloadTemplate() {
166
- if (!props.canDownloadTemplate) return
167
- closeCsvMenu()
168
- emit('download-template')
169
- }
170
- function chooseDownloadPrefilled() {
171
- if (!props.canDownloadTemplate) return
172
- closeCsvMenu()
173
- emit('download-prefilled')
158
+ const csvMenuItems = computed<ActionMenuItem[]>(() => [
159
+ { id: 'upload', label: 'Upload CSV' },
160
+ { id: 'template', label: 'Download template', disabled: !props.canDownloadTemplate },
161
+ { id: 'prefilled', label: 'Download pre-filled', disabled: !props.canDownloadTemplate },
162
+ ])
163
+ function onCsvMenuSelect(item: ActionMenuItem) {
164
+ if (item.id === 'upload') pickCsv()
165
+ else if (item.id === 'template') emit('download-template')
166
+ else emit('download-prefilled')
174
167
  }
175
168
 
169
+ const QC_ROUTE_OPTIONS: { value: QcRoute; label: string }[] = [
170
+ { value: 'Overlay only', label: 'Overlay' },
171
+ { value: 'Exclude', label: 'Exclude' },
172
+ { value: 'Mix into groups', label: 'Mix in' },
173
+ ]
174
+
176
175
  // Uncontrolled fallback state (used by the standalone story). In controlled
177
176
  // mode AutoGroupModal supplies `enabled` / `layers` / `groups` / `route` +
178
177
  // `qcChip` and the `toggle` / `move` / `update:route` events drive the engine
179
178
  // instead of this local state. `orderInner` tracks the uncontrolled layer
180
179
  // order: field indices in toggle order, first entry = hierarchy layer 1.
181
- const onInner = ref<boolean[]>(props.initialOn ? [...props.initialOn] : props.fields.map(field => !field.rep))
180
+ // `layers` without `enabled`: the parent owns the order, the toggles are local.
181
+ const startLayers = props.enabled === undefined ? props.layers : undefined
182
+ const onInner = ref<boolean[]>(
183
+ props.initialOn
184
+ ? [...props.initialOn]
185
+ : startLayers?.map(layer => layer !== null) ?? props.fields.map(field => !field.rep),
186
+ )
182
187
  const orderInner = ref<number[]>(onInner.value.flatMap((isOn, i) => (isOn ? [i] : [])))
183
188
  const routeInner = ref<QcRoute>(props.initialRoute)
184
189
 
@@ -192,7 +197,13 @@ const currentRoute = computed(() => props.route ?? routeInner.value)
192
197
  // passes `layers` from the engine; controlled-without-layers keeps the legacy
193
198
  // field-order numbering; uncontrolled mode derives from the local toggle order.
194
199
  const layers = computed<(number | null)[]>(() => {
195
- if (props.layers !== undefined) return props.layers
200
+ if (props.layers !== undefined && controlledEnabled.value) return props.layers
201
+ if (props.layers !== undefined) {
202
+ // Local toggles over the parent's order: a field that is switched off
203
+ // leaves its layer, and one that is switched on goes last.
204
+ let last = Math.max(0, ...props.layers.map(layer => layer ?? 0))
205
+ return props.layers.map((layer, i) => (on.value[i] ? layer ?? ++last : null))
206
+ }
196
207
  if (controlledEnabled.value) {
197
208
  let layer = 0
198
209
  return props.fields.map((_, i) => (on.value[i] ? ++layer : null))
@@ -210,6 +221,14 @@ const layerOrder = computed<number[]>(() =>
210
221
  .map(entry => entry.i),
211
222
  )
212
223
 
224
+ // The two lists of the layout: grouped fields in layer order, then the rest.
225
+ // `index` is the card index that the `toggle` / `move` / `rename` events carry.
226
+ interface FieldCard { index: number; field: SmartGroupField }
227
+ const onCards = computed<FieldCard[]>(() => layerOrder.value.map(index => ({ index, field: props.fields[index] })))
228
+ const offCards = computed<FieldCard[]>(() =>
229
+ props.fields.flatMap((field, index) => (layers.value[index] === null ? [{ index, field }] : [])),
230
+ )
231
+
213
232
  const computedBase = computed(() =>
214
233
  computeFieldGroups(props.samples, props.fields, on.value, props.palette, layerOrder.value),
215
234
  )
@@ -218,13 +237,8 @@ const groups = computed(() => props.groups ?? appendQcGroup(computedBase.value,
218
237
  const activeFactors = computed(() => layerOrder.value.map(i => props.fields[i].key))
219
238
  const counts = computed(() => baseGroups.value.map(group => group.count))
220
239
  const uniform = computed(() => counts.value.length > 0 && counts.value.every(count => count === counts.value[0]))
221
- const repField = computed(() => props.fields.find(field => field.rep))
222
- const repOn = computed(() => props.fields.some((field, i) => field.rep && on.value[i]))
223
- const repRange = computed(() => {
224
- const parts = (repField.value?.vals ?? '').split(',').map(part => part.trim()).filter(Boolean)
225
- if (parts.length === 0) return ''
226
- return parts.length > 1 ? `${parts[0]}–${parts[parts.length - 1]}` : parts[0]
227
- })
240
+ const repOnField = computed(() => props.fields.find((field, i) => field.rep && on.value[i]))
241
+ const repOn = computed(() => repOnField.value !== undefined)
228
242
 
229
243
  const summary = computed(() =>
230
244
  fieldRecipeSummary({ activeFactors: activeFactors.value, repOn: repOn.value, baseGroups: baseGroups.value }),
@@ -234,13 +248,11 @@ const maxCount = computed(() => Math.max(...groups.value.map(group => group.coun
234
248
  const qcCount = computed(() => props.qcCount ?? Math.max(0, props.headerCount - props.samples.length))
235
249
  const realCount = computed(() => Math.max(0, props.headerCount - qcCount.value))
236
250
  const sampleTotal = computed(() => sampleTotalFor(currentRoute.value, realCount.value, qcCount.value))
237
- const qcChip = computed(() =>
238
- props.qcChip !== undefined
239
- ? props.qcChip
240
- : currentRoute.value !== 'Mix into groups'
241
- ? { name: 'QC_Pool', count: qcCount.value }
242
- : null,
243
- )
251
+ const qcChips = computed<FieldRecipeQcChip[]>(() => {
252
+ if (props.qcChips !== undefined) return props.qcChips
253
+ if (props.qcChip !== undefined) return props.qcChip ? [props.qcChip] : []
254
+ return currentRoute.value !== 'Mix into groups' ? [{ name: 'QC_Pool', count: qcCount.value }] : []
255
+ })
244
256
  const footHint = computed(() =>
245
257
  currentRoute.value === 'Exclude'
246
258
  ? ` · ${qcCount.value} QC excluded`
@@ -249,7 +261,19 @@ const footHint = computed(() =>
249
261
  : ` · ${qcCount.value} QC overlaid`,
250
262
  )
251
263
 
264
+ // A toggle moves the row to the other list, a keyboard move reorders the rows
265
+ // and a rename replaces the row. Each time the focused control is re-created,
266
+ // so focus goes back to the same control of the same field.
267
+ const root = ref<HTMLElement | null>(null)
268
+ const controlOf = (index: number, selector: string): HTMLElement | null =>
269
+ root.value?.querySelector<HTMLElement>(`[data-field="${index}"] ${selector}`) ?? null
270
+ async function refocus(index: number, selector: string) {
271
+ await nextTick()
272
+ controlOf(index, selector)?.focus()
273
+ }
274
+
252
275
  function toggle(index: number) {
276
+ void refocus(index, '[role="switch"]')
253
277
  if (controlledEnabled.value) {
254
278
  emit('toggle', index)
255
279
  return
@@ -263,7 +287,7 @@ function toggle(index: number) {
263
287
  }
264
288
 
265
289
  function moveLayer(index: number, direction: 'up' | 'down') {
266
- if (controlledEnabled.value) {
290
+ if (controlledEnabled.value || props.layers !== undefined) {
267
291
  emit('move', index, direction)
268
292
  return
269
293
  }
@@ -276,6 +300,55 @@ function moveLayer(index: number, direction: 'up' | 'down') {
276
300
  orderInner.value = next
277
301
  }
278
302
 
303
+ // Drag a grouped field to another layer, or press Alt + arrow on its handle.
304
+ const reorder = useListReorder<FieldCard>({
305
+ items: () => onCards.value,
306
+ getId: card => card.field.key,
307
+ getLabel: card => card.field.key,
308
+ itemNoun: 'layer',
309
+ getElement: id => {
310
+ const card = onCards.value.find(candidate => candidate.field.key === id)
311
+ return card ? controlOf(card.index, '.fr-row__grip') : null
312
+ },
313
+ onReorder: ({ fromIndex, toIndex }) => {
314
+ const { index } = onCards.value[fromIndex]
315
+ const direction = toIndex < fromIndex ? 'up' : 'down'
316
+ for (let step = 0; step < Math.abs(toIndex - fromIndex); step++) moveLayer(index, direction)
317
+ },
318
+ })
319
+
320
+ // Inline rename of one field at a time. The name is the identity of a field,
321
+ // so a name that another field has is refused and the input stays open.
322
+ const renaming = ref<number | null>(null)
323
+ const draftName = ref('')
324
+ const renameTaken = ref(false)
325
+ const renameInput = ref<HTMLInputElement[]>([])
326
+ async function startRename(card: FieldCard) {
327
+ renaming.value = card.index
328
+ renameTaken.value = false
329
+ draftName.value = card.field.key
330
+ await nextTick()
331
+ renameInput.value[0]?.select()
332
+ }
333
+ function endRename(card: FieldCard, restoreFocus: boolean) {
334
+ renaming.value = null
335
+ renameTaken.value = false
336
+ if (restoreFocus) void refocus(card.index, '.fr-row__rename')
337
+ }
338
+ function commitRename(card: FieldCard, fromBlur = false) {
339
+ if (renaming.value !== card.index) return
340
+ const name = draftName.value.trim()
341
+ const taken = name !== card.field.key
342
+ && (props.fields.some(field => field.key === name) || (props.reservedNames?.includes(name) ?? false))
343
+ if (taken && !fromBlur) {
344
+ renameTaken.value = true
345
+ return
346
+ }
347
+ // Leaving the input drops a name that is in use; focus stays where the user put it.
348
+ endRename(card, !fromBlur)
349
+ if (name && name !== card.field.key && !taken) emit('rename', card.index, name)
350
+ }
351
+
279
352
  function onRouteChange(value: QcRoute) {
280
353
  if (controlledRoute.value) {
281
354
  emit('update:route', value)
@@ -296,59 +369,34 @@ function onApply() {
296
369
  </script>
297
370
 
298
371
  <template>
299
- <div class="sg-modal fr">
372
+ <div ref="root" class="sg-modal fr">
300
373
  <!-- header -->
301
374
  <div class="sg-head">
302
375
  <Icon :d="ICON.bolt" :size="18" />
303
376
  <span class="sg-head__title">Group samples</span>
304
377
  <span class="sg-head__sub">· {{ headerCount }} samples<template v-if="experimentLabel"> from {{ experimentLabel }}</template></span>
305
378
  <div class="sg-head__spacer" />
306
- <div ref="csvMenuRoot" class="fr-csv">
307
- <button
308
- ref="csvMenuTrigger"
309
- type="button"
310
- class="fr-upload"
311
- aria-haspopup="menu"
312
- :aria-expanded="csvMenuOpen"
313
- @click="toggleCsvMenu"
314
- >
315
- <Icon :d="ICON.upload" :size="14" /> CSV <Icon :d="ICON.chev" :size="13" />
316
- </button>
317
- <div v-if="csvMenuOpen" class="fr-csv-menu" role="menu">
318
- <button type="button" class="fr-csv-item" role="menuitem" @click="chooseUpload">
319
- <Icon :d="ICON.upload" :size="14" /> Upload CSV
320
- </button>
321
- <button
322
- type="button"
323
- class="fr-csv-item"
324
- role="menuitem"
325
- :disabled="!canDownloadTemplate"
326
- :aria-disabled="!canDownloadTemplate"
327
- :title="canDownloadTemplate ? undefined : 'Load samples first'"
328
- @click="chooseDownloadTemplate"
329
- >
330
- <Icon :d="ICON.download" :size="14" /> Download template
331
- </button>
379
+ <ActionMenu label="CSV" :items="csvMenuItems" @select="onCsvMenuSelect">
380
+ <template #trigger="{ open, toggle: toggleMenu, keydown }">
332
381
  <button
333
382
  type="button"
334
- class="fr-csv-item"
335
- role="menuitem"
336
- :disabled="!canDownloadTemplate"
337
- :aria-disabled="!canDownloadTemplate"
338
- :title="canDownloadTemplate ? undefined : 'Load samples first'"
339
- @click="chooseDownloadPrefilled"
383
+ class="fr-upload"
384
+ aria-haspopup="menu"
385
+ :aria-expanded="open"
386
+ @click="toggleMenu"
387
+ @keydown="keydown"
340
388
  >
341
- <Icon :d="ICON.download" :size="14" /> Download pre-filled
389
+ <Icon :d="ICON.upload" :size="14" /> CSV <Icon :d="ICON.chev" :size="13" />
342
390
  </button>
343
- </div>
344
- <input
345
- ref="fileInput"
346
- type="file"
347
- accept=".csv,.tsv,text/csv,text/tab-separated-values"
348
- hidden
349
- @change="onFileChange"
350
- />
351
- </div>
391
+ </template>
392
+ </ActionMenu>
393
+ <input
394
+ ref="fileInput"
395
+ type="file"
396
+ accept=".csv,.tsv,text/csv,text/tab-separated-values"
397
+ hidden
398
+ @change="onFileChange"
399
+ />
352
400
  <div class="sg-mode" role="tablist" aria-label="Grouping mode">
353
401
  <button type="button" class="sg-mode__btn sg-mode__btn--on" role="tab" :aria-selected="true">Auto</button>
354
402
  <button type="button" class="sg-mode__btn" role="tab" :aria-selected="false" @click="emit('manual')">Manual</button>
@@ -375,80 +423,149 @@ function onApply() {
375
423
  <LoadingSpinner size="md" label="Loading experiment metadata" />
376
424
  <span>Loading experiment metadata…</span>
377
425
  </div>
378
- <div class="sg-lead">
379
- <span class="sg-lead__q">Group by which fields?</span>
426
+ <div v-if="fields.length === 0 && headerCount === 0 && !loading" class="fr-blank" data-test="recipe-empty">
427
+ <span class="sg-lead__q">No samples to group</span>
380
428
  <span class="sg-lead__hint">
381
- MINT read the structure of your sample names. Switch on the factors that define your groups —
382
- replicate fields are detected and left off.
429
+ Upload a CSV with one sample column and one column for each field, or close this dialog and select
430
+ samples first.
383
431
  </span>
432
+ <BaseButton variant="secondary" size="sm" @click="pickCsv">
433
+ <template #icon-left><Icon :d="ICON.upload" :size="15" /></template>
434
+ Upload CSV
435
+ </BaseButton>
384
436
  </div>
437
+ <template v-else>
438
+ <div v-if="anatomy && anatomy.length > 0" class="fr-anatomy">
439
+ <span class="fr-label">How MINT read your names</span>
440
+ <div class="fr-anatomy__tokens">
441
+ <span v-for="(token, t) in anatomy" :key="t" class="fr-token" :class="'fr-token--' + token.state">
442
+ <span class="fr-token__text">{{ token.text }}</span>
443
+ <span class="fr-token__caption">{{ token.caption }}</span>
444
+ </span>
445
+ </div>
446
+ </div>
385
447
 
386
- <div class="sg-recipe">
387
- <div
388
- v-for="(field, i) in fields"
389
- :key="field.key"
390
- class="sg-field"
391
- :class="{ 'sg-field--on': on[i], 'fr-field--warn': field.rep && on[i] }"
392
- >
393
- <div class="fr-field__head">
394
- <span class="sg-field__name">{{ field.key }}</span>
395
- <span class="sg-field__role" :class="{ 'sg-field__role--rep': field.rep }">{{ field.role }}</span>
448
+ <span class="fr-label">Group by</span>
449
+ <div class="sg-recipe">
450
+ <div v-if="onCards.length === 0" class="fr-empty">
451
+ {{ fields.length === 0 ? 'These sample names have no field to group by.' : 'No field is on. Switch on a field below.' }}
396
452
  </div>
397
- <button
398
- class="sg-switch fr-switch"
399
- :class="{ on: on[i] }"
400
- role="switch"
401
- :aria-checked="on[i]"
402
- :aria-label="'Group by ' + field.key"
403
- @click="toggle(i)"
404
- />
405
- <div v-if="on[i] && layers[i] !== null" class="sg-field__order">
406
- <span class="sg-field__layer">Layer {{ layers[i] }}</span>
407
- <button
408
- type="button"
409
- class="sg-field__order-btn"
410
- :disabled="layers[i] === 1"
411
- :aria-label="'Move ' + field.key + ' up a layer'"
412
- title="Move up a layer"
413
- @click.stop="moveLayer(i, 'up')"
414
- >
415
- <Icon :d="ICON.chevUp" :size="12" />
416
- </button>
453
+ <div
454
+ v-for="(card, position) in onCards"
455
+ :key="card.field.key"
456
+ class="sg-field sg-field--on fr-row"
457
+ :class="{
458
+ 'fr-field--warn': card.field.rep,
459
+ 'fr-row--dragging': reorder.isDragging(card.field.key),
460
+ 'fr-row--over': reorder.isDragOver(card.field.key),
461
+ }"
462
+ data-test="field-on"
463
+ :data-field="card.index"
464
+ :draggable="renaming !== card.index"
465
+ @dragstart="reorder.onDragStart($event, card, position)"
466
+ @dragover="reorder.onDragOver($event, card, position)"
467
+ @dragleave="reorder.onDragLeave"
468
+ @drop="reorder.onDrop($event, card, position)"
469
+ @dragend="reorder.onDragEnd"
470
+ >
417
471
  <button
418
472
  type="button"
419
- class="sg-field__order-btn"
420
- :disabled="layers[i] === layerOrder.length"
421
- :aria-label="'Move ' + field.key + ' down a layer'"
422
- title="Move down a layer"
423
- @click.stop="moveLayer(i, 'down')"
473
+ class="fr-row__grip"
474
+ :aria-label="`Move ${card.field.key}, layer ${position + 1} of ${onCards.length}. Press Alt and an arrow key.`"
475
+ @keydown="reorder.onKeydown($event, card, position)"
424
476
  >
425
- <Icon :d="ICON.chev" :size="12" />
477
+ <Icon :d="ICON.grip" :size="14" />
426
478
  </button>
479
+ <span class="fr-row__layer">{{ position + 1 }}</span>
480
+ <input
481
+ v-if="renaming === card.index"
482
+ ref="renameInput"
483
+ v-model="draftName"
484
+ class="fr-row__name-input"
485
+ :aria-label="'New name for ' + card.field.key"
486
+ :aria-invalid="renameTaken || undefined"
487
+ @input="renameTaken = false"
488
+ @keydown.enter.prevent="commitRename(card)"
489
+ @keydown.esc.stop="endRename(card, true)"
490
+ @blur="commitRename(card, true)"
491
+ />
492
+ <span v-if="renaming === card.index && renameTaken" class="fr-row__error" role="alert">
493
+ Another field has this name
494
+ </span>
495
+ <template v-else>
496
+ <span class="sg-field__name">{{ card.field.key }}</span>
497
+ <button
498
+ v-if="renamable"
499
+ type="button"
500
+ class="fr-row__rename"
501
+ :aria-label="'Rename ' + card.field.key"
502
+ @click="startRename(card)"
503
+ >
504
+ <Icon :d="ICON.pencil" :size="12" />
505
+ </button>
506
+ </template>
507
+ <span class="sg-field__vals">{{ card.field.vals }}</span>
508
+ <span v-if="hints?.[card.index]" class="fr-row__hint">{{ hints[card.index] }}</span>
509
+ <BaseToggle
510
+ size="sm"
511
+ :model-value="true"
512
+ :aria-label="'Group by ' + card.field.key"
513
+ @update:model-value="toggle(card.index)"
514
+ />
427
515
  </div>
428
- <div class="sg-field__vals">{{ field.vals }}</div>
429
516
  </div>
430
- </div>
517
+ <span :style="reorder.announcerStyle" aria-live="polite">{{ reorder.announcement.value }}</span>
431
518
 
432
- <!-- inline plain-english summary -->
433
- <div v-if="summary.kind === 'mute'" class="sg-summary fr-summary--mute">
434
- <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
435
- <span>Turn on at least one field to form groups.</span>
436
- </div>
437
- <div v-else-if="summary.kind === 'warn'" class="sg-summary sg-summary--warn">
438
- <span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
439
- <span>
440
- Including <b>{{ repField?.key ?? 'Replicate' }}</b> makes every sample unique —
441
- <b>{{ baseGroups.length }} groups of 1</b>. Turn it off to keep <code>{{ repRange }}</code> together.
442
- </span>
443
- </div>
444
- <div v-else class="sg-summary">
445
- <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
446
- <span>
447
- Grouping by <b>{{ activeFactors.join(' × ') }}</b> →
448
- <b>{{ baseGroups.length }} groups<template v-if="uniform"> of {{ counts[0] }}</template></b>.
449
- Replicates kept together.
450
- </span>
451
- </div>
519
+ <template v-if="offCards.length > 0">
520
+ <span class="fr-label">Left off</span>
521
+ <div class="sg-recipe">
522
+ <div
523
+ v-for="card in offCards"
524
+ :key="card.field.key"
525
+ class="sg-field fr-row"
526
+ data-test="field-off"
527
+ :data-field="card.index"
528
+ >
529
+ <span class="sg-field__name">{{ card.field.key }}</span>
530
+ <span class="sg-field__role" :class="{ 'sg-field__role--rep': card.field.rep }">{{ card.field.role }}</span>
531
+ <span class="sg-field__vals">{{ card.field.vals }}</span>
532
+ <BaseToggle
533
+ size="sm"
534
+ :model-value="false"
535
+ :aria-label="'Group by ' + card.field.key"
536
+ @update:model-value="toggle(card.index)"
537
+ />
538
+ </div>
539
+ </div>
540
+ </template>
541
+
542
+ <!-- inline plain-english summary -->
543
+ <div v-if="summary.kind === 'mute'" class="sg-summary fr-summary--mute">
544
+ <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
545
+ <span>Turn on at least one field to form groups.</span>
546
+ </div>
547
+ <div v-else-if="summary.kind === 'warn'" class="sg-summary sg-summary--warn">
548
+ <span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
549
+ <span>
550
+ Including <b>{{ repOnField?.key }}</b> makes every sample unique —
551
+ <b>{{ baseGroups.length }} groups of 1</b>. Switch it off to keep replicates together.
552
+ </span>
553
+ </div>
554
+ <div v-else-if="summary.kind === 'singletons'" class="sg-summary sg-summary--warn">
555
+ <span class="sg-summary__icon"><Icon :d="ICON.warn" :size="16" /></span>
556
+ <span>
557
+ <b>{{ counts.filter(count => count === 1).length }} of {{ baseGroups.length }} groups</b> have one sample.
558
+ Switch off a field to form larger groups.
559
+ </span>
560
+ </div>
561
+ <div v-else class="sg-summary">
562
+ <span class="sg-summary__icon"><Icon :d="ICON.info" :size="16" /></span>
563
+ <span>
564
+ Grouping by <b>{{ activeFactors.join(' × ') }}</b> →
565
+ <b>{{ baseGroups.length }} groups<template v-if="uniform"> of {{ counts[0] }}</template></b>.
566
+ </span>
567
+ </div>
568
+ </template>
452
569
  </div>
453
570
 
454
571
  <!-- rail -->
@@ -473,23 +590,26 @@ function onApply() {
473
590
  <div class="sg-qc">
474
591
  <div class="sg-qc__head">
475
592
  <span class="sg-qc__title">Quality control</span>
476
- <select
477
- class="sg-qc__route"
478
- :value="currentRoute"
479
- @change="onRouteChange(($event.target as HTMLSelectElement).value as QcRoute)"
593
+ <SegmentedControl
594
+ class="fr-qc__route"
595
+ size="sm"
596
+ variant="card"
597
+ aria-label="Quality control routing"
598
+ :model-value="currentRoute"
599
+ :options="QC_ROUTE_OPTIONS"
600
+ @update:model-value="onRouteChange($event as QcRoute)"
601
+ />
602
+ </div>
603
+ <div class="fr-qc__chips">
604
+ <span
605
+ v-for="chip in qcChips"
606
+ :key="chip.name"
607
+ class="sg-qc__chip"
608
+ :class="{ 'fr-qc__chip--off': currentRoute === 'Exclude' }"
480
609
  >
481
- <option>Overlay only</option>
482
- <option>Exclude</option>
483
- <option>Mix into groups</option>
484
- </select>
610
+ <span class="sg-dot" :style="QC_DOT_STYLE" />{{ chip.name }} ×{{ chip.count }}
611
+ </span>
485
612
  </div>
486
- <span
487
- v-if="qcChip"
488
- class="sg-qc__chip"
489
- :class="{ 'fr-qc__chip--off': currentRoute === 'Exclude' }"
490
- >
491
- <span class="sg-dot" :style="QC_DOT_STYLE" />{{ qcChip.name }} ×{{ qcChip.count }}
492
- </span>
493
613
  </div>
494
614
  </div>
495
615
  </div>
@@ -89,7 +89,7 @@ function handleSelect(page: PageSelectorItem) {
89
89
  </span>
90
90
  </button>
91
91
 
92
- <div v-if="isOpen" class="mint-popover mint-page-selector__menu" role="menu">
92
+ <div v-if="isOpen" class="mint-page-selector__menu" role="menu">
93
93
  <div class="mint-page-selector__menu-title">Go to</div>
94
94
  <ActionItemInternal
95
95
  v-for="page in normalizedPages"
@@ -98,8 +98,8 @@ function handleSelect(page: PageSelectorItem) {
98
98
  :to="page.to"
99
99
  :disabled="page.disabled"
100
100
  :class="[
101
- 'mint-menu-item mint-page-selector__item',
102
- { 'mint-menu-item--selected mint-page-selector__item--active': page.id === current?.id },
101
+ 'mint-page-selector__item',
102
+ { 'mint-page-selector__item--active': page.id === current?.id },
103
103
  { 'mint-page-selector__item--disabled': page.disabled },
104
104
  ]"
105
105
  role="menuitem"
@@ -121,9 +121,6 @@ function handleSelect(page: PageSelectorItem) {
121
121
  </slot>
122
122
  </span>
123
123
  <span class="mint-page-selector__item-label">{{ page.label }}</span>
124
- <svg class="mint-menu-item__check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
125
- <path d="M3.5 8.5l3 3 6-7" />
126
- </svg>
127
124
  </ActionItemInternal>
128
125
  </div>
129
126
  </div>