@morscherlab/mint-sdk 1.4.0-beta.20 → 1.4.0-beta.22

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 (97) hide show
  1. package/dist/{AlertBox-CzLg0UaL.js → AlertBox-DwgNTEqf.js} +3 -3
  2. package/dist/{AlertBox-CzLg0UaL.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  3. package/dist/{AppSidebar-6oTT-gPO.js → AppSidebar-CZZaxni0.js} +7 -7
  4. package/dist/{AppSidebar-6oTT-gPO.js.map → AppSidebar-CZZaxni0.js.map} +1 -1
  5. package/dist/{ConfirmDialog-CSls2Nuv.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  6. package/dist/{ConfirmDialog-CSls2Nuv.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  7. package/dist/{ExperimentPopover-9l4QZlvQ.js → ExperimentPopover-DujiuQIO.js} +5 -5
  8. package/dist/{ExperimentPopover-9l4QZlvQ.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  9. package/dist/{ExperimentSelectorModal-pR5fK3pA.js → ExperimentSelectorModal-B4j4eUOJ.js} +5 -5
  10. package/dist/{ExperimentSelectorModal-pR5fK3pA.js.map → ExperimentSelectorModal-B4j4eUOJ.js.map} +1 -1
  11. package/dist/{SegmentedControl-CJ7EAmOq.js → SegmentedControl-DyUPH3I5.js} +2 -2
  12. package/dist/{SegmentedControl-CJ7EAmOq.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  13. package/dist/{SettingsModal-Dyitgzfp.js → SettingsModal-D2MOOtuO.js} +7 -7
  14. package/dist/{SettingsModal-Dyitgzfp.js.map → SettingsModal-D2MOOtuO.js.map} +1 -1
  15. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  19. package/dist/composables/useFileImport.d.ts +7 -0
  20. package/dist/composables/useFloatingPanel.d.ts +29 -0
  21. package/dist/{controlSchemaModel-DCUOx8ll.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  22. package/dist/{controlSchemaModel-DCUOx8ll.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  23. package/dist/{data-DBnmeBZX.js → data-Boeh4uPI.js} +12 -11
  24. package/dist/{data-DBnmeBZX.js.map → data-Boeh4uPI.js.map} +1 -1
  25. package/dist/data.d.ts +1 -1
  26. package/dist/data.js +9 -9
  27. package/dist/{experimentStatus-BFrKpHAH.js → experimentStatus-DCn2QKG7.js} +2 -2
  28. package/dist/{experimentStatus-BFrKpHAH.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  29. package/dist/index.js +18 -18
  30. package/dist/{install-qjELyNwc.js → install-BEHp7Mo-.js} +15 -15
  31. package/dist/{install-qjELyNwc.js.map → install-BEHp7Mo-.js.map} +1 -1
  32. package/dist/install.js +1 -1
  33. package/dist/{lab-UyTbokQC.js → lab-D61eaX6f.js} +10 -10
  34. package/dist/{lab-UyTbokQC.js.map → lab-D61eaX6f.js.map} +1 -1
  35. package/dist/lab.js +4 -4
  36. package/dist/{records-CR_pYzIP.js → records-D4aQXyZu.js} +132 -72
  37. package/dist/records-D4aQXyZu.js.map +1 -0
  38. package/dist/{runtime-CoHhBP10.js → runtime-q-R0CNLh.js} +9 -9
  39. package/dist/{runtime-CoHhBP10.js.map → runtime-q-R0CNLh.js.map} +1 -1
  40. package/dist/runtime.js +6 -6
  41. package/dist/{shell-BDuZf2zb.js → shell-6CGFvUUw.js} +5 -5
  42. package/dist/{shell-BDuZf2zb.js.map → shell-6CGFvUUw.js.map} +1 -1
  43. package/dist/shell.js +5 -5
  44. package/dist/styles.css +36 -33
  45. package/dist/{useDropdownState-Bb2N4m40.js → useDropdownState-uFIz6Yv8.js} +2 -2
  46. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  47. package/dist/{useExperimentSamples-Dno1fFMq.js → useExperimentSamples-QEFoq4tf.js} +47 -5
  48. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  49. package/dist/{useListReorder-CQ9OCScc.js → useListReorder-DsuzwWGS.js} +47 -97
  50. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  51. package/dist/{useRackEditor-wL3kPrRT.js → useRackEditor-KpZ4iJ6o.js} +20 -30
  52. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  53. package/dist/{useRealtime-C3whhVvb.js → useRealtime-B58Lo-Tm.js} +3 -3
  54. package/dist/{useRealtime-C3whhVvb.js.map → useRealtime-B58Lo-Tm.js.map} +1 -1
  55. package/dist/{useRequest-CDkrXNU7.js → useRequest-BuDmW8Jc.js} +7 -36
  56. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  57. package/package.json +3 -2
  58. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  59. package/src/__tests__/components/BaseModal.test.ts +13 -0
  60. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  61. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  62. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  63. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  64. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  65. package/src/components/ActionMenu.vue +6 -37
  66. package/src/components/BaseModal.vue +2 -1
  67. package/src/components/Tooltip.vue +9 -32
  68. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  69. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  70. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  71. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  72. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  73. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  74. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  75. package/src/composables/useFileImport.ts +51 -1
  76. package/src/composables/useFloatingPanel.ts +107 -0
  77. package/src/data.ts +1 -0
  78. package/src/styles/components/action-menu.css +1 -1
  79. package/src/styles/components/app-layout.css +3 -1
  80. package/src/styles/components/date-picker.css +4 -2
  81. package/src/styles/components/datetime-picker.css +4 -4
  82. package/src/styles/components/jobs-status-tray.css +3 -3
  83. package/src/styles/components/modal.css +1 -1
  84. package/src/styles/components/onboarding.css +2 -2
  85. package/src/styles/components/searchable-select.css +3 -7
  86. package/src/styles/components/time-picker.css +3 -6
  87. package/src/styles/components/toast.css +1 -1
  88. package/src/styles/components/tooltip.css +2 -2
  89. package/src/styles/components/unit-input.css +1 -1
  90. package/src/styles/components/well-edit-popup.css +2 -2
  91. package/src/styles/variables.css +6 -0
  92. package/dist/records-CR_pYzIP.js.map +0 -1
  93. package/dist/useDropdownState-Bb2N4m40.js.map +0 -1
  94. package/dist/useExperimentSamples-Dno1fFMq.js.map +0 -1
  95. package/dist/useListReorder-CQ9OCScc.js.map +0 -1
  96. package/dist/useRackEditor-wL3kPrRT.js.map +0 -1
  97. package/dist/useRequest-CDkrXNU7.js.map +0 -1
@@ -0,0 +1,68 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { defineComponent, nextTick, ref } from 'vue'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { computePosition } from '@floating-ui/dom'
5
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
6
+
7
+ const cleanup = vi.fn()
8
+ vi.mock('@floating-ui/dom', async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import('@floating-ui/dom')>()),
10
+ autoUpdate: vi.fn((_reference: unknown, _floating: unknown, update: () => void) => {
11
+ update()
12
+ return cleanup
13
+ }),
14
+ computePosition: vi.fn(async () => ({ x: 10, y: 20, placement: 'bottom-start', strategy: 'fixed', middlewareData: {} })),
15
+ }))
16
+
17
+ /** A trigger and the panel placed next to it. */
18
+ const Panel = defineComponent({
19
+ setup() {
20
+ const trigger = ref<HTMLElement | null>(null)
21
+ const panel = ref<HTMLElement | null>(null)
22
+ const isOpen = ref(true)
23
+ const { style } = useFloatingPanel(trigger, panel, isOpen)
24
+ return { trigger, panel, isOpen, style }
25
+ },
26
+ template: '<div><button ref="trigger">Open</button><div v-if="isOpen" ref="panel" class="panel" :style="style" /></div>',
27
+ })
28
+
29
+ async function settle() {
30
+ for (let i = 0; i < 3; i++) await nextTick()
31
+ }
32
+
33
+ describe('useFloatingPanel', () => {
34
+ afterEach(() => {
35
+ cleanup.mockClear()
36
+ document.body.innerHTML = ''
37
+ })
38
+
39
+ it.each([
40
+ ['closes', (wrapper: ReturnType<typeof mount>) => { (wrapper.vm as unknown as { isOpen: boolean }).isOpen = false }],
41
+ ['unmounts', (wrapper: ReturnType<typeof mount>) => wrapper.unmount()],
42
+ ])('should stop autoUpdate when the panel %s', async (_, end) => {
43
+ const wrapper = mount(Panel, { attachTo: document.body })
44
+ await settle()
45
+ expect(cleanup).not.toHaveBeenCalled()
46
+
47
+ end(wrapper)
48
+ await settle()
49
+
50
+ expect(cleanup).toHaveBeenCalledTimes(1)
51
+ })
52
+
53
+ it.each([
54
+ [false, ''],
55
+ [true, 'hidden'],
56
+ ])('should set the panel visibility to %j when referenceHidden is %s', async (referenceHidden, visibility) => {
57
+ vi.mocked(computePosition).mockResolvedValueOnce({
58
+ x: 10, y: 20, placement: 'bottom-start', strategy: 'fixed', middlewareData: { hide: { referenceHidden } },
59
+ })
60
+ mount(Panel, { attachTo: document.body })
61
+ await settle()
62
+
63
+ const panel = document.querySelector<HTMLElement>('.panel')!
64
+ expect(panel.style.position).toBe('fixed')
65
+ expect(panel.style.top).toBe('20px')
66
+ expect(panel.style.visibility).toBe(visibility)
67
+ })
68
+ })
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */
3
- import { computePosition, flip, offset, shift } from '@floating-ui/dom'
4
3
  import { Check, Ellipsis } from 'lucide-vue-next'
5
- import { nextTick, onMounted, ref, watch } from 'vue'
4
+ import { nextTick, onMounted, ref } from 'vue'
6
5
  import { useDropdownState } from '../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../composables/useFloatingPanel'
7
7
  import { useMenuKeyboard } from '../composables/useMenuKeyboard'
8
8
  import type { ActionMenuItem } from '../types/components'
9
9
  import BaseButton from './BaseButton.vue'
@@ -30,19 +30,15 @@ defineSlots<{
30
30
  }>()
31
31
 
32
32
  const panelRef = ref<HTMLElement | null>(null)
33
- const panelStyle = ref<Record<string, string>>({ visibility: 'hidden' })
34
33
 
35
- // Disclosure state (outside-click, Escape, scroll/resize closing, focus return)
36
- // comes from the shared composable; only floating-ui placement stays local.
34
+ // Disclosure state (outside-click, Escape, focus return) and placement come from
35
+ // the shared composables; the open menu follows its trigger on scroll.
37
36
  const { isOpen, rootRef, triggerRef, open, close } = useDropdownState({
38
- closeOnScroll: true,
39
37
  insideRefs: [panelRef],
40
- onOpen: () => {
41
- panelStyle.value = { visibility: 'hidden' }
42
- emit('open')
43
- },
38
+ onOpen: () => emit('open'),
44
39
  onClose: () => emit('close'),
45
40
  })
41
+ const { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })
46
42
 
47
43
  // BaseButton owns the actual <button>, so the trigger element is resolved from
48
44
  // the wrapper once mounted rather than bound with a template ref.
@@ -57,27 +53,6 @@ function menuItems(): HTMLElement[] {
57
53
  )
58
54
  }
59
55
 
60
- async function positionPanel() {
61
- const trigger = triggerRef.value
62
- const panel = panelRef.value
63
- if (!trigger || !panel) return
64
-
65
- const position = await computePosition(trigger, panel, {
66
- placement: 'bottom-end',
67
- strategy: 'fixed',
68
- middleware: [
69
- offset(4),
70
- flip(),
71
- shift({ padding: 8 }),
72
- ],
73
- })
74
- panelStyle.value = {
75
- left: `${position.x}px`,
76
- top: `${position.y}px`,
77
- visibility: 'visible',
78
- }
79
- }
80
-
81
56
  /** `focusFirst` is false when the keyboard composable owns the initial focus. */
82
57
  function openMenu(focusFirst = true) {
83
58
  if (props.disabled || isOpen.value) return
@@ -105,12 +80,6 @@ function handleSelect(item: ActionMenuItem) {
105
80
  emit('select', item)
106
81
  close()
107
82
  }
108
-
109
- watch(isOpen, async (opened) => {
110
- if (!opened) return
111
- await nextTick()
112
- await positionPanel()
113
- })
114
83
  </script>
115
84
 
116
85
  <template>
@@ -107,8 +107,9 @@ watch(() => props.open, (isOpen) => {
107
107
  if (!isOpen && index !== -1) openModals.splice(index, 1)
108
108
  }, { immediate: true })
109
109
 
110
+ // A control inside the modal that handles Escape (an open select) prevents the default first.
110
111
  function handleEscapeKeydown(event: KeyboardEvent) {
111
- if (event.key === 'Escape' && props.closeOnEscape && props.open && openModals[openModals.length - 1] === self) {
112
+ if (event.key === 'Escape' && !event.defaultPrevented && props.closeOnEscape && props.open && openModals[openModals.length - 1] === self) {
112
113
  close()
113
114
  }
114
115
  }
@@ -1,8 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  /** Floating tooltip using @floating-ui with auto-flip, delay, optional keyboard shortcut badge, and body Teleport. */
3
- import { ref, onBeforeUnmount, nextTick } from 'vue'
4
- import { computePosition, flip, offset, shift, type Placement } from '@floating-ui/dom'
5
- import { useEventListener } from '../composables/useEventListener'
3
+ import { ref, onBeforeUnmount } from 'vue'
4
+ import { useFloatingPanel } from '../composables/useFloatingPanel'
6
5
 
7
6
  interface Props {
8
7
  text: string
@@ -21,40 +20,19 @@ const props = withDefaults(defineProps<Props>(), {
21
20
 
22
21
  const tooltipId = `mint-tooltip-${Math.random().toString(36).slice(2, 9)}`
23
22
  const visible = ref(false)
24
- const actualPlacement = ref<Placement>(props.position)
25
23
  const triggerRef = ref<HTMLElement | null>(null)
26
24
  const contentRef = ref<HTMLElement | null>(null)
27
- const coords = ref({ top: '0px', left: '0px' })
28
25
  let timeoutId: ReturnType<typeof setTimeout> | undefined
29
26
 
30
- async function updatePosition() {
31
- if (!triggerRef.value || !contentRef.value) return
32
-
33
- const { x, y, placement } = await computePosition(triggerRef.value, contentRef.value, {
34
- placement: props.position,
35
- middleware: [
36
- offset(6),
37
- flip(),
38
- shift({ padding: 8 }),
39
- ],
40
- })
41
-
42
- coords.value = { top: `${y}px`, left: `${x}px` }
43
- actualPlacement.value = placement
44
- }
45
-
46
- useEventListener(() => window, 'scroll', updatePosition, {
47
- passive: true,
48
- capture: true,
49
- enabled: visible,
27
+ // Follows the trigger while visible; the placement after flip picks the arrow side.
28
+ const { style: coords, placement: actualPlacement } = useFloatingPanel(triggerRef, contentRef, visible, {
29
+ placement: () => props.position,
30
+ offset: 6,
50
31
  })
51
- useEventListener(() => window, 'resize', updatePosition, { enabled: visible })
52
32
 
53
- async function show() {
54
- timeoutId = setTimeout(async () => {
33
+ function show() {
34
+ timeoutId = setTimeout(() => {
55
35
  visible.value = true
56
- await nextTick()
57
- await updatePosition()
58
36
  }, props.delay)
59
37
  }
60
38
 
@@ -96,8 +74,7 @@ function resolvedMaxWidth(value: string | number | undefined): string | undefine
96
74
  maxWidth ? 'mint-tooltip__content--multiline' : '',
97
75
  ]"
98
76
  :style="{
99
- top: coords.top,
100
- left: coords.left,
77
+ ...coords,
101
78
  maxWidth: resolvedMaxWidth(maxWidth),
102
79
  }"
103
80
  role="tooltip"
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** DatePicker type="date": input that opens a calendar dropdown for selecting a single date, with clearable support. */
3
- import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
4
- import { ref, computed, nextTick, onBeforeUnmount, watch, useTemplateRef } from 'vue'
3
+ import { ref, computed, nextTick, watch, useTemplateRef } from 'vue'
5
4
  import { useCalendarGrid, type CalendarGridDay } from '../../composables/useCalendarGrid'
6
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
8
8
  import CalendarGridPanelInternal from './CalendarGridPanelInternal.vue'
9
9
 
@@ -30,11 +30,13 @@ const emit = defineEmits<{
30
30
  }>()
31
31
 
32
32
  const dropdownRef = ref<HTMLElement | null>(null)
33
- const dropdownStyle = ref<Record<string, string>>({})
34
33
  const { isOpen, triggerRef, open, close, toggle } = useDropdownState({
35
34
  rootRef: useTemplateRef<HTMLElement>('container'),
36
35
  insideRefs: [dropdownRef],
37
36
  })
37
+ // floating-ui keeps the panel anchored (flip/shift) while the page scrolls,
38
+ // instead of the panel closing on every scroll or resize.
39
+ const { style: dropdownStyle } = useFloatingPanel(triggerRef, dropdownRef, isOpen)
38
40
  // The panel is a non-modal dialog: Tab cycles inside it while it is open, and
39
41
  // focus returns to the input when it closes.
40
42
  useFocusTrap({ containerRef: dropdownRef, active: isOpen })
@@ -83,41 +85,6 @@ function handleTriggerKeydown(event: KeyboardEvent) {
83
85
  }
84
86
  }
85
87
 
86
- // floating-ui keeps the panel anchored (flip/shift) while the page scrolls,
87
- // instead of the panel closing on every scroll or resize.
88
- let stopAutoUpdate: (() => void) | null = null
89
-
90
- async function updateDropdownPosition() {
91
- const trigger = triggerRef.value
92
- const panel = dropdownRef.value
93
- if (!trigger || !panel) return
94
-
95
- const { x, y } = await computePosition(trigger, panel, {
96
- placement: 'bottom-start',
97
- strategy: 'fixed',
98
- middleware: [offset(4), flip(), shift({ padding: 8 })],
99
- })
100
- dropdownStyle.value = {
101
- position: 'fixed',
102
- top: `${y}px`,
103
- left: `${x}px`,
104
- zIndex: '9999',
105
- }
106
- }
107
-
108
- function stopPositioning() {
109
- stopAutoUpdate?.()
110
- stopAutoUpdate = null
111
- }
112
-
113
- function startPositioning() {
114
- const trigger = triggerRef.value
115
- const panel = dropdownRef.value
116
- if (!trigger || !panel) return
117
- stopPositioning()
118
- stopAutoUpdate = autoUpdate(trigger, panel, () => void updateDropdownPosition())
119
- }
120
-
121
88
  /** Moves focus into the calendar grid: selected day, else today, else the first day. */
122
89
  function focusCalendar() {
123
90
  const panel = dropdownRef.value
@@ -130,21 +97,15 @@ function focusCalendar() {
130
97
  }
131
98
 
132
99
  watch(isOpen, async (opened) => {
133
- if (!opened) {
134
- stopPositioning()
135
- return
136
- }
100
+ if (!opened) return
137
101
  if (selectedDate.value) {
138
102
  setCurrentMonthFromDate(selectedDate.value)
139
103
  }
140
104
  await nextTick()
141
- startPositioning()
142
105
  // A second tick so this focus lands after the focus trap's initial focus.
143
106
  await nextTick()
144
107
  focusCalendar()
145
108
  })
146
-
147
- onBeforeUnmount(stopPositioning)
148
109
  </script>
149
110
 
150
111
  <template>
@@ -202,6 +163,7 @@ onBeforeUnmount(stopPositioning)
202
163
  :style="dropdownStyle"
203
164
  role="dialog"
204
165
  aria-label="Date picker"
166
+ @keydown.escape.prevent="close(true)"
205
167
  >
206
168
  <CalendarGridPanelInternal
207
169
  :week-days="weekDays"
@@ -3,6 +3,7 @@
3
3
  import { computed, ref, watch, nextTick, useTemplateRef } from 'vue'
4
4
  import { useCalendarGrid, type CalendarGridDay } from '../../composables/useCalendarGrid'
5
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
6
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
7
8
  import { formatTime, formatTimeSlot, generateTimeSlots, toMinutes } from '../../utils/time'
8
9
  import CalendarGridPanelInternal from './CalendarGridPanelInternal.vue'
@@ -37,10 +38,13 @@ const emit = defineEmits<{
37
38
  }>()
38
39
 
39
40
  const dropdownRef = ref<HTMLElement | null>(null)
41
+ const rootRef = useTemplateRef<HTMLElement>('root')
40
42
  const { isOpen, open, close, toggle } = useDropdownState({
41
- rootRef: useTemplateRef<HTMLElement>('root'),
43
+ rootRef,
42
44
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
43
45
  })
46
+ // Fixed below the field, so a modal body's overflow does not clip it; it stays in place in the DOM.
47
+ const { style: dropdownStyle } = useFloatingPanel(rootRef, dropdownRef, isOpen)
44
48
  // Non-modal dialog: Tab stays inside the panel, focus returns to the input on close.
45
49
  useFocusTrap({ containerRef: dropdownRef, active: isOpen })
46
50
  const {
@@ -230,8 +234,10 @@ watch(isOpen, async (opened) => {
230
234
  v-if="isOpen"
231
235
  ref="dropdownRef"
232
236
  class="mint-popover mint-datetime-picker__dropdown"
237
+ :style="dropdownStyle"
233
238
  role="dialog"
234
239
  aria-label="Date and time picker"
240
+ @keydown.escape.prevent="close(true)"
235
241
  >
236
242
  <!-- Calendar section -->
237
243
  <div class="mint-datetime-picker__calendar-section">
@@ -3,6 +3,7 @@
3
3
  import { ref, computed, watch, nextTick, useId, useTemplateRef } from 'vue'
4
4
  import type { TimeSlotDisabledFn } from '../../types'
5
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
6
7
  import {
7
8
  compareTime,
8
9
  findNearestTimeSlotIndex,
@@ -40,12 +41,16 @@ const emit = defineEmits<{
40
41
  }>()
41
42
 
42
43
  const listRef = ref<HTMLUListElement | null>(null)
44
+ const dropdownRef = ref<HTMLElement | null>(null)
43
45
  const highlightedIndex = ref(-1)
44
46
  const listboxId = `mint-time-picker-listbox-${useId().replace(/:/g, '')}`
47
+ const rootRef = useTemplateRef<HTMLElement>('root')
45
48
  const { isOpen, open, close, toggle } = useDropdownState({
46
- rootRef: useTemplateRef<HTMLElement>('root'),
49
+ rootRef,
47
50
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
48
51
  })
52
+ // Fixed below the field and at least as wide, so a modal body's overflow does not clip it; it stays in place in the DOM.
53
+ const { style: dropdownStyle } = useFloatingPanel(rootRef, dropdownRef, isOpen, { matchWidth: true })
49
54
 
50
55
  /** Id of the highlighted slot, published to the input as `aria-activedescendant`. */
51
56
  const activeSlotId = computed(() =>
@@ -258,7 +263,9 @@ watch(isOpen, (open) => {
258
263
  >
259
264
  <div
260
265
  v-if="isOpen"
266
+ ref="dropdownRef"
261
267
  class="mint-popover mint-time-picker__dropdown"
268
+ :style="dropdownStyle"
262
269
  >
263
270
  <ul
264
271
  :id="listboxId"
@@ -4,9 +4,9 @@
4
4
  * previews the value in each unit; units with a `factor` also get a hint in the neighbour unit
5
5
  * nearest to 1–1000.
6
6
  */
7
- import { computePosition, flip, offset, shift } from '@floating-ui/dom'
8
- import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7
+ import { computed, nextTick, ref, useTemplateRef } from 'vue'
9
8
  import { useDropdownState } from '../../composables/useDropdownState'
9
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
10
10
  import { useMenuKeyboard } from '../../composables/useMenuKeyboard'
11
11
  import type { UnitOption } from '../../types'
12
12
 
@@ -148,27 +148,13 @@ function selectUnit(newUnitValue: string) {
148
148
 
149
149
  // ---- Unit menu (disclosure + WAI-ARIA keyboard, shared with ActionMenu) ----
150
150
  const menuRef = ref<HTMLElement | null>(null)
151
- // Transparent (not visibility:hidden) until placed, so the open-time focus can land on an option.
152
- const menuStyle = ref<Record<string, string>>({ opacity: '0' })
153
151
  const { isOpen, rootRef, open, close } = useDropdownState({
154
152
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
155
- closeOnScroll: true,
156
153
  insideRefs: [menuRef],
157
- onOpen: () => { menuStyle.value = { opacity: '0' } },
158
154
  })
159
155
 
160
- /** Teleported + fixed like ActionMenu, so overflow:hidden/auto ancestors never clip the menu. */
161
- watch(isOpen, async (opened) => {
162
- if (!opened) return
163
- await nextTick()
164
- if (!rootRef.value || !menuRef.value) return
165
- const { x, y } = await computePosition(rootRef.value, menuRef.value, {
166
- placement: 'bottom-end',
167
- strategy: 'fixed',
168
- middleware: [offset(4), flip(), shift({ padding: 8 })],
169
- })
170
- menuStyle.value = { left: `${x}px`, top: `${y}px` }
171
- })
156
+ /** Teleported + fixed like ActionMenu, so overflow:hidden/auto ancestors never clip the menu; it follows the field on scroll. */
157
+ const { style: menuStyle } = useFloatingPanel(rootRef, menuRef, isOpen, { placement: 'bottom-end' })
172
158
 
173
159
  function menuItems(): HTMLElement[] {
174
160
  return menuRef.value ? Array.from(menuRef.value.querySelectorAll<HTMLElement>('[role="option"]')) : []
@@ -4,6 +4,7 @@ import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
4
4
  import { Check, ChevronDown, X } from 'lucide-vue-next'
5
5
  import type { OptionPrimitive, SelectOption, SelectOptionInput } from '../../types'
6
6
  import { useEventListener } from '../../composables/useEventListener'
7
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
8
  import { useListSelection } from '../../composables/useListSelection'
8
9
  import { normalizeOptionInput } from '../../utils/options'
9
10
 
@@ -49,6 +50,7 @@ const listboxId = `mint-multi-select-${id}`
49
50
  const root = ref<HTMLElement | null>(null)
50
51
  const field = ref<HTMLElement | null>(null)
51
52
  const search = ref<HTMLInputElement | null>(null)
53
+ const panel = ref<HTMLElement | null>(null)
52
54
  const open = ref(false)
53
55
  const query = ref('')
54
56
  const activeIndex = ref(-1)
@@ -136,6 +138,9 @@ function handleSearchKeydown(event: KeyboardEvent): void {
136
138
  event.preventDefault()
137
139
  }
138
140
 
141
+ // Fixed next to the field, so a modal body's overflow does not clip it; it stays in place in the DOM.
142
+ const { style: panelStyle } = useFloatingPanel(field, panel, open, { matchWidth: true })
143
+
139
144
  watch(visibleOptions, options => { activeIndex.value = options.length > 0 ? 0 : -1 })
140
145
  watch(() => props.disabled, disabled => { if (disabled) void closePanel() })
141
146
  useEventListener(() => document, 'pointerdown', event => {
@@ -192,7 +197,7 @@ useEventListener(() => document, 'pointerdown', event => {
192
197
  <ChevronDown class="mint-multi-select__icon" :size="14" aria-hidden="true" />
193
198
  </div>
194
199
 
195
- <div v-if="open" class="mint-popover mint-searchable-select__panel">
200
+ <div v-if="open" ref="panel" class="mint-popover mint-searchable-select__panel" :style="panelStyle">
196
201
  <div class="mint-searchable-select__search-wrap">
197
202
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
198
203
  <circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" />
@@ -4,6 +4,7 @@ import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
4
4
  import { ChevronsUpDown } from 'lucide-vue-next'
5
5
  import type { SelectOption, SelectOptionInput } from '../../types'
6
6
  import { useEventListener } from '../../composables/useEventListener'
7
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
8
  import { normalizeOptionInput } from '../../utils/options'
8
9
 
9
10
  interface Props {
@@ -53,6 +54,7 @@ const triggerId = `${listboxId}-trigger`
53
54
  const root = ref<HTMLElement | null>(null)
54
55
  const trigger = ref<HTMLButtonElement | null>(null)
55
56
  const search = ref<HTMLInputElement | null>(null)
57
+ const panel = ref<HTMLElement | null>(null)
56
58
  const open = ref(false)
57
59
  const query = ref('')
58
60
  const activeIndex = ref(-1)
@@ -144,6 +146,9 @@ function handleTriggerKeydown(event: KeyboardEvent): void {
144
146
  })
145
147
  }
146
148
 
149
+ // Fixed next to the trigger, so a modal body's overflow does not clip it; it stays in place in the DOM.
150
+ const { style: panelStyle } = useFloatingPanel(trigger, panel, open, { matchWidth: true })
151
+
147
152
  watch(visibleOptions, options => { activeIndex.value = firstEnabledIndex(options) })
148
153
  watch(() => props.disabled, disabled => { if (disabled) void closePanel() })
149
154
  useEventListener(() => document, 'pointerdown', event => {
@@ -180,7 +185,7 @@ useEventListener(() => document, 'pointerdown', event => {
180
185
  <ChevronsUpDown :size="14" aria-hidden="true" />
181
186
  </button>
182
187
 
183
- <div v-if="open" class="mint-popover mint-searchable-select__panel">
188
+ <div v-if="open" ref="panel" class="mint-popover mint-searchable-select__panel" :style="panelStyle">
184
189
  <div class="mint-searchable-select__search-wrap">
185
190
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
186
191
  <circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" />
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** Single-well inspector for WellPlate / PlateEditor, composed from SDK fields (3b). */
3
3
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
4
- import { computePosition, flip, shift } from '@floating-ui/dom'
5
4
  import { ChevronRight, X } from 'lucide-vue-next'
6
5
  import type { Well, WellEditData, WellEditField } from '../../types'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
8
8
  import { sampleTypeMarker } from '../../utils/sampleTypeMarker'
9
9
  import { DEFAULT_SAMPLE_TYPE_COLORS } from '../WellPlate.colors'
@@ -157,28 +157,18 @@ function handleInlineEnter(event: KeyboardEvent) {
157
157
  emit('close')
158
158
  }
159
159
 
160
- // Placement: flip and clamp to the viewport. Transparent (not hidden) until
161
- // placed, so the focus trap can move focus in on mount.
160
+ // Placement at the clicked point: flip and clamp to the viewport. Inline, the
161
+ // inspector stays in place and is not positioned.
162
162
  const containerRef = ref<HTMLElement | null>(null)
163
- const panelStyle = ref<Record<string, string>>({ opacity: '0' })
164
-
165
- async function place() {
166
- const panel = containerRef.value
167
- if (!panel || props.inline) return
163
+ const pointAnchor = () => {
164
+ if (props.inline) return null
168
165
  const { x, y } = props.position
169
- const anchor = {
170
- getBoundingClientRect: () => ({ x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0 }),
171
- }
172
- const placed = await computePosition(anchor, panel, {
173
- placement: 'right-start',
174
- strategy: 'fixed',
175
- middleware: [flip(), shift({ padding: 8 })],
176
- })
177
- panelStyle.value = { left: `${placed.x}px`, top: `${placed.y}px` }
166
+ return { getBoundingClientRect: () => ({ x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0 }) }
178
167
  }
179
-
180
- watch(() => props.position, place)
181
- watch(showAdvanced, () => nextTick(place))
168
+ const { style: panelStyle } = useFloatingPanel(pointAnchor, containerRef, computed(() => !props.inline), {
169
+ placement: 'right-start',
170
+ offset: 0,
171
+ })
182
172
 
183
173
  // The parent mounts and unmounts the popup, so the trap follows the lifecycle,
184
174
  // the same way BaseModal drives it off `modelValue`.
@@ -200,7 +190,6 @@ watch(() => props.wellId, () => { if (props.inline) focusInline() }, { flush: 'p
200
190
  onMounted(() => {
201
191
  if (props.inline) return focusInline()
202
192
  trapActive.value = true
203
- void place()
204
193
  // Runs after the trap's own first-element focus: start in the name field.
205
194
  void nextTick(() => containerRef.value?.querySelector<HTMLInputElement>('[data-field="label"] input')?.focus())
206
195
  })
@@ -223,7 +212,7 @@ onBeforeUnmount(() => {
223
212
  :aria-label="`Edit well ${wellId}`"
224
213
  :tabindex="inline ? undefined : -1"
225
214
  @click="inline || $event.stopPropagation()"
226
- @keydown.escape="commit(); emit('close')"
215
+ @keydown.escape.prevent="commit(); emit('close')"
227
216
  @keydown="inline && handleInlineEnter($event)"
228
217
  @focusout="handleFocusout"
229
218
  >
@@ -1,4 +1,5 @@
1
1
  import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
2
+ import type { SheetData } from 'read-excel-file/universal'
2
3
  import { readErrorMessage } from '../utils/errors'
3
4
 
4
5
  export interface DelimitedTable {
@@ -116,6 +117,53 @@ export function readFileAsText(file: File, encoding?: string): Promise<string> {
116
117
  })
117
118
  }
118
119
 
120
+ /**
121
+ * Read a `.csv`, `.tsv` or `.xlsx` upload as comma-delimited CSV text.
122
+ * A `.csv` file (or any other extension) comes back unchanged. An `.xlsx`
123
+ * file gives its first sheet; each cell is its stored value, so number
124
+ * formats such as leading zeros are not applied. Dates become ISO text.
125
+ */
126
+ export async function readTableFileAsCsv(file: File): Promise<string> {
127
+ const name = file.name.toLowerCase()
128
+ if (name.endsWith('.xlsx')) return rowsToCsv(await readFirstSheet(file))
129
+ const text = await readFileAsText(file)
130
+ return name.endsWith('.tsv') ? rowsToCsv(parseDelimitedRows(text, '\t')) : text
131
+ }
132
+
133
+ async function readFirstSheet(file: File): Promise<string[][]> {
134
+ // Lazy import: plugins that never read .xlsx do not load the library.
135
+ const { readSheet } = await import('read-excel-file/universal')
136
+ let rows: SheetData<string>
137
+ try {
138
+ // Keep the stored text: no trim, and no float parse that rounds big numbers.
139
+ rows = await readSheet(file, { trim: false, parseNumber: text => text })
140
+ } catch {
141
+ throw new Error(`${file.name} is not a valid .xlsx workbook.`)
142
+ }
143
+ return rows.map(row => row.map(formatCell))
144
+ }
145
+
146
+ function formatCell(value: unknown): string {
147
+ if (value === null || value === undefined) return ''
148
+ if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
149
+ if (value instanceof Date) {
150
+ // Excel stores dates as float day counts; round off the float error.
151
+ const [date, time] = new Date(Math.round(value.getTime() / 1000) * 1000)
152
+ .toISOString().slice(0, 19).split('T')
153
+ if (date === '1899-12-30') return time // a time-only cell
154
+ return time === '00:00:00' ? date : `${date}T${time}`
155
+ }
156
+ return String(value)
157
+ }
158
+
159
+ function rowsToCsv(rows: readonly (readonly string[])[]): string {
160
+ return rows.map(row => row.map(quoteCsvField).join(',')).join('\n')
161
+ }
162
+
163
+ function quoteCsvField(value: string): string {
164
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
165
+ }
166
+
119
167
  export function parseDelimitedText(
120
168
  text: string,
121
169
  options: ParseDelimitedTextOptions = {},
@@ -170,8 +218,10 @@ function parseDelimitedRows(text: string, delimiter: string): string[][] {
170
218
  if (quoted && next === '"') {
171
219
  field += '"'
172
220
  index++
173
- } else {
221
+ } else if (quoted || field === '') {
174
222
  quoted = !quoted
223
+ } else {
224
+ field += '"' // a quote inside an unquoted field is text
175
225
  }
176
226
  continue
177
227
  }