@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.
- package/dist/{AlertBox-CzLg0UaL.js → AlertBox-DwgNTEqf.js} +3 -3
- package/dist/{AlertBox-CzLg0UaL.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
- package/dist/{AppSidebar-6oTT-gPO.js → AppSidebar-CZZaxni0.js} +7 -7
- package/dist/{AppSidebar-6oTT-gPO.js.map → AppSidebar-CZZaxni0.js.map} +1 -1
- package/dist/{ConfirmDialog-CSls2Nuv.js → ConfirmDialog-CjWoxAD1.js} +2 -2
- package/dist/{ConfirmDialog-CSls2Nuv.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
- package/dist/{ExperimentPopover-9l4QZlvQ.js → ExperimentPopover-DujiuQIO.js} +5 -5
- package/dist/{ExperimentPopover-9l4QZlvQ.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-pR5fK3pA.js → ExperimentSelectorModal-B4j4eUOJ.js} +5 -5
- package/dist/{ExperimentSelectorModal-pR5fK3pA.js.map → ExperimentSelectorModal-B4j4eUOJ.js.map} +1 -1
- package/dist/{SegmentedControl-CJ7EAmOq.js → SegmentedControl-DyUPH3I5.js} +2 -2
- package/dist/{SegmentedControl-CJ7EAmOq.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
- package/dist/{SettingsModal-Dyitgzfp.js → SettingsModal-D2MOOtuO.js} +7 -7
- package/dist/{SettingsModal-Dyitgzfp.js.map → SettingsModal-D2MOOtuO.js.map} +1 -1
- package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
- package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
- package/dist/composables/useFileImport.d.ts +7 -0
- package/dist/composables/useFloatingPanel.d.ts +29 -0
- package/dist/{controlSchemaModel-DCUOx8ll.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
- package/dist/{controlSchemaModel-DCUOx8ll.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
- package/dist/{data-DBnmeBZX.js → data-Boeh4uPI.js} +12 -11
- package/dist/{data-DBnmeBZX.js.map → data-Boeh4uPI.js.map} +1 -1
- package/dist/data.d.ts +1 -1
- package/dist/data.js +9 -9
- package/dist/{experimentStatus-BFrKpHAH.js → experimentStatus-DCn2QKG7.js} +2 -2
- package/dist/{experimentStatus-BFrKpHAH.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
- package/dist/index.js +18 -18
- package/dist/{install-qjELyNwc.js → install-BEHp7Mo-.js} +15 -15
- package/dist/{install-qjELyNwc.js.map → install-BEHp7Mo-.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-UyTbokQC.js → lab-D61eaX6f.js} +10 -10
- package/dist/{lab-UyTbokQC.js.map → lab-D61eaX6f.js.map} +1 -1
- package/dist/lab.js +4 -4
- package/dist/{records-CR_pYzIP.js → records-D4aQXyZu.js} +132 -72
- package/dist/records-D4aQXyZu.js.map +1 -0
- package/dist/{runtime-CoHhBP10.js → runtime-q-R0CNLh.js} +9 -9
- package/dist/{runtime-CoHhBP10.js.map → runtime-q-R0CNLh.js.map} +1 -1
- package/dist/runtime.js +6 -6
- package/dist/{shell-BDuZf2zb.js → shell-6CGFvUUw.js} +5 -5
- package/dist/{shell-BDuZf2zb.js.map → shell-6CGFvUUw.js.map} +1 -1
- package/dist/shell.js +5 -5
- package/dist/styles.css +36 -33
- package/dist/{useDropdownState-Bb2N4m40.js → useDropdownState-uFIz6Yv8.js} +2 -2
- package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
- package/dist/{useExperimentSamples-Dno1fFMq.js → useExperimentSamples-QEFoq4tf.js} +47 -5
- package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
- package/dist/{useListReorder-CQ9OCScc.js → useListReorder-DsuzwWGS.js} +47 -97
- package/dist/useListReorder-DsuzwWGS.js.map +1 -0
- package/dist/{useRackEditor-wL3kPrRT.js → useRackEditor-KpZ4iJ6o.js} +20 -30
- package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
- package/dist/{useRealtime-C3whhVvb.js → useRealtime-B58Lo-Tm.js} +3 -3
- package/dist/{useRealtime-C3whhVvb.js.map → useRealtime-B58Lo-Tm.js.map} +1 -1
- package/dist/{useRequest-CDkrXNU7.js → useRequest-BuDmW8Jc.js} +7 -36
- package/dist/useRequest-BuDmW8Jc.js.map +1 -0
- package/package.json +3 -2
- package/src/__tests__/components/ActionMenu.test.ts +2 -2
- package/src/__tests__/components/BaseModal.test.ts +13 -0
- package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
- package/src/__tests__/composables/useFileImport.test.ts +36 -0
- package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
- package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
- package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
- package/src/components/ActionMenu.vue +6 -37
- package/src/components/BaseModal.vue +2 -1
- package/src/components/Tooltip.vue +9 -32
- package/src/components/internal/DatePickerDateInternal.vue +7 -45
- package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
- package/src/components/internal/DatePickerTimeInternal.vue +8 -1
- package/src/components/internal/NumberUnitInputInternal.vue +4 -18
- package/src/components/internal/SelectMultipleInternal.vue +6 -1
- package/src/components/internal/SelectSearchableInternal.vue +6 -1
- package/src/components/internal/WellEditPopupInternal.vue +11 -22
- package/src/composables/useFileImport.ts +51 -1
- package/src/composables/useFloatingPanel.ts +107 -0
- package/src/data.ts +1 -0
- package/src/styles/components/action-menu.css +1 -1
- package/src/styles/components/app-layout.css +3 -1
- package/src/styles/components/date-picker.css +4 -2
- package/src/styles/components/datetime-picker.css +4 -4
- package/src/styles/components/jobs-status-tray.css +3 -3
- package/src/styles/components/modal.css +1 -1
- package/src/styles/components/onboarding.css +2 -2
- package/src/styles/components/searchable-select.css +3 -7
- package/src/styles/components/time-picker.css +3 -6
- package/src/styles/components/toast.css +1 -1
- package/src/styles/components/tooltip.css +2 -2
- package/src/styles/components/unit-input.css +1 -1
- package/src/styles/components/well-edit-popup.css +2 -2
- package/src/styles/variables.css +6 -0
- package/dist/records-CR_pYzIP.js.map +0 -1
- package/dist/useDropdownState-Bb2N4m40.js.map +0 -1
- package/dist/useExperimentSamples-Dno1fFMq.js.map +0 -1
- package/dist/useListReorder-CQ9OCScc.js.map +0 -1
- package/dist/useRackEditor-wL3kPrRT.js.map +0 -1
- 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
|
+
})
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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,
|
|
36
|
-
//
|
|
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
|
|
4
|
-
import {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
54
|
-
timeoutId = setTimeout(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
161
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
}
|