js-kompo-utils 1.0.28 → 1.0.30
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/components/VlMultiStateCheckbox.test.js +125 -0
- package/components/VlMultiStateCheckbox.vue +183 -0
- package/components/VlSelect.vue +84 -16
- package/components/VlSwipeableTabs.vue +5 -1
- package/components/__test_helpers__/mountKompo.js +39 -0
- package/components/test-setup.js +25 -0
- package/package.json +6 -2
- package/vitest.config.js +11 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import VlMultiStateCheckbox from './VlMultiStateCheckbox.vue'
|
|
3
|
+
import { mountKompo } from './__test_helpers__/mountKompo'
|
|
4
|
+
|
|
5
|
+
const SINGLE = { mode: 'single', values: [1, 2, 3], colors: ['bg-r', 'bg-y', 'bg-g'] }
|
|
6
|
+
const SECTION = { mode: 'section', values: [1, 2, 3], colors: ['bg-r', 'bg-y', 'bg-g'] }
|
|
7
|
+
|
|
8
|
+
function mountMscb(config, value) {
|
|
9
|
+
return mountKompo(VlMultiStateCheckbox, { config, value })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('VlMultiStateCheckbox — single mode rendering', () => {
|
|
13
|
+
it('shows the color matching the current value', () => {
|
|
14
|
+
const w = mountMscb(SINGLE, 2)
|
|
15
|
+
expect(w.find('.vl-mscb__swatch').classes()).toContain('bg-y')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('shows empty when value is null', () => {
|
|
19
|
+
const w = mountMscb(SINGLE, null)
|
|
20
|
+
expect(w.find('.vl-mscb__swatch').classes()).toContain('vl-mscb__swatch--empty')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('shows empty when value is not in the values list', () => {
|
|
24
|
+
const w = mountMscb(SINGLE, 99)
|
|
25
|
+
expect(w.find('.vl-mscb__swatch').classes()).toContain('vl-mscb__swatch--empty')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('VlMultiStateCheckbox — single mode click', () => {
|
|
30
|
+
it.each([
|
|
31
|
+
[null, 1],
|
|
32
|
+
[1, 2],
|
|
33
|
+
[2, 3],
|
|
34
|
+
[3, null],
|
|
35
|
+
[99, 1],
|
|
36
|
+
])('cycles %s → %s', async (from, to) => {
|
|
37
|
+
const w = mountMscb(SINGLE, from)
|
|
38
|
+
await w.find('.vl-mscb').trigger('click')
|
|
39
|
+
expect(w.vm.component.value).toBe(to)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not change value when readonly', async () => {
|
|
43
|
+
const w = mountMscb({ ...SINGLE, readonly: true }, 1)
|
|
44
|
+
await w.find('.vl-mscb').trigger('click')
|
|
45
|
+
expect(w.vm.component.value).toBe(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it.each(['keydown.enter', 'keydown.space'])('cycles via %s', async (key) => {
|
|
49
|
+
const w = mountMscb(SINGLE, null)
|
|
50
|
+
await w.find('.vl-mscb').trigger(key)
|
|
51
|
+
expect(w.vm.component.value).toBe(1)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('VlMultiStateCheckbox — section mode rendering', () => {
|
|
56
|
+
it('renders one bar per distinct active value (mixed)', () => {
|
|
57
|
+
const w = mountMscb(SECTION, [1, 2])
|
|
58
|
+
const bars = w.findAll('.vl-mscb__bar--visible')
|
|
59
|
+
expect(bars.length).toBe(2)
|
|
60
|
+
expect(bars.at(0).classes()).toContain('bg-r')
|
|
61
|
+
expect(bars.at(1).classes()).toContain('bg-y')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders one bar when all entries share a value (uniform)', () => {
|
|
65
|
+
const w = mountMscb(SECTION, [2, 2, 2])
|
|
66
|
+
const bars = w.findAll('.vl-mscb__bar--visible')
|
|
67
|
+
expect(bars.length).toBe(1)
|
|
68
|
+
expect(bars.at(0).classes()).toContain('bg-y')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it.each([
|
|
72
|
+
['empty array', []],
|
|
73
|
+
['all-null', [null, null]],
|
|
74
|
+
['null value', null],
|
|
75
|
+
])('renders no bars when %s', (_label, value) => {
|
|
76
|
+
const w = mountMscb(SECTION, value)
|
|
77
|
+
expect(w.findAll('.vl-mscb__bar--visible').length).toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('skips entries not in the values whitelist', () => {
|
|
81
|
+
const w = mountMscb(SECTION, [1, 99, 2])
|
|
82
|
+
const bars = w.findAll('.vl-mscb__bar--visible')
|
|
83
|
+
expect(bars.length).toBe(2)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('VlMultiStateCheckbox — section mode click', () => {
|
|
88
|
+
it.each([
|
|
89
|
+
['uniform — advances from shared value', [2, 2], 3],
|
|
90
|
+
['uniform at last — cycles to null', [3, 3], null],
|
|
91
|
+
['mixed — starts at first state', [1, 2], 1],
|
|
92
|
+
['empty — starts at first state', [], 1],
|
|
93
|
+
])('%s', async (_label, value, expected) => {
|
|
94
|
+
const w = mountMscb(SECTION, value)
|
|
95
|
+
await w.find('.vl-mscb').trigger('click')
|
|
96
|
+
expect(w.vm.component.value).toBe(expected)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not change value when readonly', async () => {
|
|
100
|
+
const w = mountMscb({ ...SECTION, readonly: true }, [1, 2])
|
|
101
|
+
await w.find('.vl-mscb').trigger('click')
|
|
102
|
+
expect(w.vm.component.value).toEqual([1, 2])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('emits a scalar value, not an array', async () => {
|
|
106
|
+
const w = mountMscb(SECTION, [1, 1])
|
|
107
|
+
await w.find('.vl-mscb').trigger('click')
|
|
108
|
+
expect(Array.isArray(w.vm.component.value)).toBe(false)
|
|
109
|
+
expect(w.vm.component.value).toBe(2)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('updates the visual to a uniform bar at the new target after click', async () => {
|
|
113
|
+
const w = mountMscb(SECTION, [1, 2]) // mixed — click resets to states[0]=1
|
|
114
|
+
await w.find('.vl-mscb').trigger('click')
|
|
115
|
+
const bars = w.findAll('.vl-mscb__bar--visible')
|
|
116
|
+
expect(bars.length).toBe(1)
|
|
117
|
+
expect(bars.at(0).classes()).toContain('bg-r')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('renders no bars after a click that cycles past the last state to null', async () => {
|
|
121
|
+
const w = mountMscb(SECTION, [3, 3]) // uniform at last → click → null
|
|
122
|
+
await w.find('.vl-mscb').trigger('click')
|
|
123
|
+
expect(w.findAll('.vl-mscb__bar--visible').length).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<vl-form-field v-bind="$_wrapperAttributes">
|
|
3
|
+
<div class="vl-mscb inline-block"
|
|
4
|
+
v-bind="$_config('attrs') || {}"
|
|
5
|
+
:class="rootClass"
|
|
6
|
+
tabindex="0"
|
|
7
|
+
@click.stop="onClick"
|
|
8
|
+
@keydown.enter.stop.prevent="onClick"
|
|
9
|
+
@keydown.space.stop.prevent="onClick">
|
|
10
|
+
|
|
11
|
+
<span v-if="!isSection"
|
|
12
|
+
class="vl-mscb__swatch block w-[1.1rem] h-[1.1rem] border border-black rounded"
|
|
13
|
+
:class="swatchClass" />
|
|
14
|
+
|
|
15
|
+
<span v-else
|
|
16
|
+
class="vl-mscb__section flex w-[1.1rem] h-[1.1rem] border border-black rounded overflow-hidden">
|
|
17
|
+
<span v-for="bar in sectionBars" :key="bar.key"
|
|
18
|
+
class="vl-mscb__bar vl-mscb__bar--visible flex-1 h-full"
|
|
19
|
+
:class="bar.color" />
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
</vl-form-field>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
import Field from 'vue-kompo/js/form/mixins/Field'
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
name: 'VlMultiStateCheckbox',
|
|
30
|
+
mixins: [Field],
|
|
31
|
+
|
|
32
|
+
// Local display state for section mode.
|
|
33
|
+
//
|
|
34
|
+
// sectionOverride: holds the visual array right after a section click
|
|
35
|
+
// sets a cascade target (uniform across all cells). Cleared when a cell
|
|
36
|
+
// change comes in, so cellValues wins.
|
|
37
|
+
//
|
|
38
|
+
// cellValues: a live map keyed by permission_id, populated as each cell
|
|
39
|
+
// mounts and emits its initial state, then kept current via cell-change
|
|
40
|
+
// events. The section's bars derive from this map. PHP's $_value array
|
|
41
|
+
// is only used as a fallback when no cell-state events have arrived.
|
|
42
|
+
data() {
|
|
43
|
+
return { sectionOverride: null, cellValues: {} }
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
computed: {
|
|
47
|
+
isSection() { return this.$_config('mode') === 'section' },
|
|
48
|
+
states() { return this.$_config('values') || [] },
|
|
49
|
+
colors() { return this.$_config('colors') || [] },
|
|
50
|
+
isReadonly() { return this.$_readOnly || !!this.$_config('readonly') },
|
|
51
|
+
|
|
52
|
+
rootClass() {
|
|
53
|
+
return {
|
|
54
|
+
'vl-mscb--readonly': this.isReadonly,
|
|
55
|
+
'vl-mscb--section': this.isSection,
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// Single-mode swatch: highlight the color matching $_value, or empty.
|
|
60
|
+
swatchClass() {
|
|
61
|
+
const i = this.indexOf(this.$_value)
|
|
62
|
+
return i < 0 ? ['vl-mscb__swatch--empty'] : [this.colors[i] || '']
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// What array of cell values to display in section mode.
|
|
66
|
+
// Priority: explicit override (just-clicked cascade) > live cellValues
|
|
67
|
+
// populated from cell mount/change events > PHP-seeded prop array.
|
|
68
|
+
sectionDisplay() {
|
|
69
|
+
if (this.sectionOverride !== null) return this.sectionOverride
|
|
70
|
+
const live = Object.values(this.cellValues)
|
|
71
|
+
if (live.length > 0) return live
|
|
72
|
+
return Array.isArray(this.$_value) ? this.$_value : []
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Sync-group key. Cells and section sharing the same group exchange
|
|
76
|
+
// visual state via the global $kompo.events bus.
|
|
77
|
+
groupKey() { return this.$_config('group') || null },
|
|
78
|
+
permId() { return this.$_config('permission_id') || null },
|
|
79
|
+
cellEvent() { return this.groupKey ? 'mscb:cell-state:' + this.groupKey : null },
|
|
80
|
+
cascadeEvent() { return this.groupKey ? 'mscb:section-cascade:' + this.groupKey : null },
|
|
81
|
+
|
|
82
|
+
// One bar per distinct active value in sectionDisplay, restricted to
|
|
83
|
+
// known states, in first-occurrence order.
|
|
84
|
+
sectionBars() {
|
|
85
|
+
const seen = new Map()
|
|
86
|
+
for (const v of this.sectionDisplay) {
|
|
87
|
+
if (v === null || v === undefined || seen.has(v)) continue
|
|
88
|
+
const i = this.indexOf(v)
|
|
89
|
+
if (i < 0) continue
|
|
90
|
+
seen.set(v, { key: v, color: this.colors[i] })
|
|
91
|
+
}
|
|
92
|
+
return Array.from(seen.values())
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Next state on click.
|
|
96
|
+
// Single mode: advance from $_value.
|
|
97
|
+
// Section mode: if cells share one state, advance from that state;
|
|
98
|
+
// otherwise (mixed or empty) start at the first state.
|
|
99
|
+
nextValue() {
|
|
100
|
+
if (!this.isSection) return this.advance(this.$_value)
|
|
101
|
+
const bars = this.sectionBars
|
|
102
|
+
return bars.length === 1 ? this.advance(bars[0].key) : this.states[0]
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
methods: {
|
|
107
|
+
indexOf(value) {
|
|
108
|
+
if (value === null || value === undefined) return -1
|
|
109
|
+
return this.states.findIndex(s => s == value)
|
|
110
|
+
},
|
|
111
|
+
advance(from) {
|
|
112
|
+
const i = this.indexOf(from)
|
|
113
|
+
if (i < 0) return this.states[0]
|
|
114
|
+
return (i + 1 >= this.states.length) ? null : this.states[i + 1]
|
|
115
|
+
},
|
|
116
|
+
onClick() {
|
|
117
|
+
if (this.isReadonly) return
|
|
118
|
+
const target = this.nextValue
|
|
119
|
+
|
|
120
|
+
if (this.isSection) {
|
|
121
|
+
// Cascade visual: every bar becomes the new target (or none if null).
|
|
122
|
+
const len = this.sectionDisplay.length || 1
|
|
123
|
+
this.sectionOverride = target === null ? [] : new Array(len).fill(target)
|
|
124
|
+
if (this.cascadeEvent) {
|
|
125
|
+
this.$kompo.events.$emit(this.cascadeEvent, target)
|
|
126
|
+
}
|
|
127
|
+
} else if (this.cellEvent && this.permId !== null) {
|
|
128
|
+
// Single-cell click: tell the section header our new value.
|
|
129
|
+
this.$kompo.events.$emit(this.cellEvent, {
|
|
130
|
+
permission_id: this.permId,
|
|
131
|
+
value: target,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.component = Object.assign({}, this.component, { value: target })
|
|
136
|
+
this.$_changeAction()
|
|
137
|
+
},
|
|
138
|
+
$_resetSortValue() {
|
|
139
|
+
this.component.value = null
|
|
140
|
+
this.sectionOverride = null
|
|
141
|
+
this.cellValues = {}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
mounted() {
|
|
146
|
+
if (!this.groupKey) return
|
|
147
|
+
|
|
148
|
+
if (this.isSection) {
|
|
149
|
+
// Listen for cells reporting their state (initial mount + every change).
|
|
150
|
+
this.$_vlOn(this.cellEvent, ({ permission_id, value }) => {
|
|
151
|
+
this.cellValues = Object.assign({}, this.cellValues, { [permission_id]: value })
|
|
152
|
+
// A real cell change supersedes any earlier cascade override.
|
|
153
|
+
this.sectionOverride = null
|
|
154
|
+
})
|
|
155
|
+
} else if (this.permId !== null) {
|
|
156
|
+
// Cells: react to section cascade by adopting the new value
|
|
157
|
+
// and re-broadcasting their state so the section's cellValues stays current.
|
|
158
|
+
this.$_vlOn(this.cascadeEvent, (newValue) => {
|
|
159
|
+
this.component = Object.assign({}, this.component, { value: newValue })
|
|
160
|
+
this.$kompo.events.$emit(this.cellEvent, {
|
|
161
|
+
permission_id: this.permId,
|
|
162
|
+
value: newValue,
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Announce our initial value so the section can build its cellValues map.
|
|
167
|
+
this.$kompo.events.$emit(this.cellEvent, {
|
|
168
|
+
permission_id: this.permId,
|
|
169
|
+
value: this.$_value,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
beforeDestroy() {
|
|
175
|
+
if (!this.groupKey) return
|
|
176
|
+
if (this.isSection) {
|
|
177
|
+
this.$_vlOff(this.cellEvent)
|
|
178
|
+
} else {
|
|
179
|
+
this.$_vlOff(this.cascadeEvent)
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
</script>
|
package/components/VlSelect.vue
CHANGED
|
@@ -34,11 +34,15 @@
|
|
|
34
34
|
<option v-for="opt in nativeOptionsList" :key="opt.value" :value="opt.value">{{ nativeLabel(opt) }}</option>
|
|
35
35
|
</select>
|
|
36
36
|
</div>
|
|
37
|
-
<div class="vlOptions
|
|
37
|
+
<div class="vlOptions"
|
|
38
38
|
:key="optionsKey"
|
|
39
39
|
ref="selectOptions"
|
|
40
40
|
:style="optionsStyle"
|
|
41
|
-
:class="{
|
|
41
|
+
:class="{
|
|
42
|
+
'vlOptionsTeleported': useFloatingOptions,
|
|
43
|
+
'vlOptionsFlipUp': useFloatingOptions && flipUp,
|
|
44
|
+
'vlOptionsVisible': useFloatingOptions && isFocused
|
|
45
|
+
}">
|
|
42
46
|
<template v-if="filteredOptions.length">
|
|
43
47
|
<div v-for="(option,key) in filteredOptions" :key="option.value" :ref="key"
|
|
44
48
|
class="vlOption"
|
|
@@ -85,7 +89,14 @@ export default {
|
|
|
85
89
|
forceDown() {
|
|
86
90
|
return !!this.$_config('openDownward') || this.$_config('autoOptionsPlacement') === false
|
|
87
91
|
},
|
|
92
|
+
useFloatingOptions() {
|
|
93
|
+
const explicitFloating = this.$_config('floatingOptions')
|
|
94
|
+
if (explicitFloating !== undefined && explicitFloating !== null) return !!explicitFloating
|
|
95
|
+
|
|
96
|
+
return !!this.$_config('teleportedOptions') || this.forceUp
|
|
97
|
+
},
|
|
88
98
|
optionsStyle() {
|
|
99
|
+
if (!this.useFloatingOptions) return null
|
|
89
100
|
return this.isFocused ? null : { display: 'none' }
|
|
90
101
|
},
|
|
91
102
|
useNativeMobile() {
|
|
@@ -116,6 +127,14 @@ export default {
|
|
|
116
127
|
},
|
|
117
128
|
watch: {
|
|
118
129
|
isFocused(focused) {
|
|
130
|
+
if (!this.useFloatingOptions) {
|
|
131
|
+
if (!focused) {
|
|
132
|
+
this.flipUp = false
|
|
133
|
+
this.detachListeners()
|
|
134
|
+
this.resetPanelStyles()
|
|
135
|
+
}
|
|
136
|
+
return
|
|
137
|
+
}
|
|
119
138
|
if (focused) {
|
|
120
139
|
this.attachListeners()
|
|
121
140
|
this.$nextTick(() => {
|
|
@@ -129,9 +148,10 @@ export default {
|
|
|
129
148
|
}
|
|
130
149
|
},
|
|
131
150
|
filteredOptions() {
|
|
132
|
-
if (this.isFocused) this.$nextTick(() => this.queuePositionUpdate())
|
|
151
|
+
if (this.useFloatingOptions && this.isFocused) this.$nextTick(() => this.queuePositionUpdate())
|
|
133
152
|
},
|
|
134
153
|
optionsKey() {
|
|
154
|
+
if (!this.useFloatingOptions) return
|
|
135
155
|
this.$nextTick(() => {
|
|
136
156
|
this.teleportOptions()
|
|
137
157
|
if (this.isFocused) this.startPositioning()
|
|
@@ -139,7 +159,7 @@ export default {
|
|
|
139
159
|
},
|
|
140
160
|
},
|
|
141
161
|
mounted() {
|
|
142
|
-
this.teleportOptions()
|
|
162
|
+
if (this.useFloatingOptions) this.teleportOptions()
|
|
143
163
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
144
164
|
this.isTouchDevice = window.matchMedia('(pointer: coarse)').matches
|
|
145
165
|
}
|
|
@@ -183,23 +203,56 @@ export default {
|
|
|
183
203
|
tmp.innerHTML = str
|
|
184
204
|
return (tmp.textContent || tmp.innerText || '').trim()
|
|
185
205
|
},
|
|
186
|
-
teleportOptions() {
|
|
206
|
+
teleportOptions(anchor = this.anchorEl()) {
|
|
207
|
+
if (!this.useFloatingOptions) return
|
|
187
208
|
const opts = this.$refs.selectOptions
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
const context = this.floatingContext(anchor)
|
|
210
|
+
const root = context?.root
|
|
211
|
+
if (opts && root && opts.parentNode !== root) {
|
|
212
|
+
root.appendChild(opts)
|
|
190
213
|
}
|
|
191
214
|
},
|
|
192
215
|
cleanupOptions() {
|
|
216
|
+
if (!this.useFloatingOptions) return
|
|
193
217
|
const opts = this.$refs.selectOptions
|
|
194
|
-
if (opts && opts.parentNode
|
|
195
|
-
|
|
218
|
+
if (opts && opts.parentNode && this.$el && !this.$el.contains(opts)) {
|
|
219
|
+
opts.parentNode.removeChild(opts)
|
|
196
220
|
}
|
|
197
221
|
},
|
|
198
222
|
anchorEl() {
|
|
199
223
|
return this.$refs.taggableInput?.$el || this.$refs.input?.parentElement
|
|
200
224
|
},
|
|
201
225
|
overlayAncestor(anchor = this.anchorEl()) {
|
|
202
|
-
return anchor?.closest('.kompoModal, .kompoDrawer, .vl-sidebar-l') || null
|
|
226
|
+
return anchor?.closest('.kompoModal, .kompoDrawer, .vl-sidebar-l, .vl-sidebar-r') || null
|
|
227
|
+
},
|
|
228
|
+
floatingContext(anchor = this.anchorEl()) {
|
|
229
|
+
if (!anchor || typeof document === 'undefined') return null
|
|
230
|
+
|
|
231
|
+
const drawerOrSidebar = anchor.closest('.kompoDrawer, .vl-sidebar-l, .vl-sidebar-r')
|
|
232
|
+
if (drawerOrSidebar && typeof window !== 'undefined') {
|
|
233
|
+
const position = window.getComputedStyle(drawerOrSidebar).position
|
|
234
|
+
if (position && position !== 'static') {
|
|
235
|
+
return {
|
|
236
|
+
root: drawerOrSidebar,
|
|
237
|
+
strategy: 'absolute',
|
|
238
|
+
zIndex: 5001,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (this.overlayAncestor(anchor)) {
|
|
244
|
+
return {
|
|
245
|
+
root: document.body,
|
|
246
|
+
strategy: 'fixed',
|
|
247
|
+
zIndex: 5001,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
root: document.body,
|
|
253
|
+
strategy: 'absolute',
|
|
254
|
+
zIndex: 1000,
|
|
255
|
+
}
|
|
203
256
|
},
|
|
204
257
|
attachListeners() {
|
|
205
258
|
if (this._listenersAttached) return
|
|
@@ -233,7 +286,10 @@ export default {
|
|
|
233
286
|
if (!opts || !anchor || !this.isFocused) return
|
|
234
287
|
|
|
235
288
|
this.stopPositioning()
|
|
236
|
-
this.
|
|
289
|
+
this.teleportOptions(anchor)
|
|
290
|
+
const context = this.floatingContext(anchor)
|
|
291
|
+
if (!context) return
|
|
292
|
+
this.applyBasePanelStyles(opts, anchor, context)
|
|
237
293
|
opts.style.visibility = 'hidden'
|
|
238
294
|
opts.style.transform = 'translate3d(0px, 0px, 0)'
|
|
239
295
|
|
|
@@ -258,15 +314,16 @@ export default {
|
|
|
258
314
|
this._autoUpdateCleanup()
|
|
259
315
|
this._autoUpdateCleanup = null
|
|
260
316
|
},
|
|
261
|
-
applyBasePanelStyles(opts, anchor) {
|
|
262
|
-
|
|
263
|
-
|
|
317
|
+
applyBasePanelStyles(opts, anchor, context = this.floatingContext(anchor)) {
|
|
318
|
+
if (!context) return
|
|
319
|
+
|
|
320
|
+
opts.style.position = context.strategy
|
|
264
321
|
opts.style.top = '0px'
|
|
265
322
|
opts.style.left = '0px'
|
|
266
323
|
opts.style.bottom = 'auto'
|
|
267
324
|
opts.style.width = Math.round(anchor.getBoundingClientRect().width) + 'px'
|
|
268
325
|
opts.style.maxHeight = '240px'
|
|
269
|
-
opts.style.zIndex =
|
|
326
|
+
opts.style.zIndex = String(context.zIndex)
|
|
270
327
|
opts.style.willChange = 'transform'
|
|
271
328
|
},
|
|
272
329
|
resetPanelStyles() {
|
|
@@ -315,6 +372,17 @@ export default {
|
|
|
315
372
|
const referenceEl = anchor
|
|
316
373
|
const positionVersion = this._positionVersion || 0
|
|
317
374
|
const { placement, auto } = this.placementConfig()
|
|
375
|
+
const context = this.floatingContext(referenceEl)
|
|
376
|
+
if (!context) return
|
|
377
|
+
|
|
378
|
+
if (floatingEl.parentNode !== context.root) {
|
|
379
|
+
this.$nextTick(() => {
|
|
380
|
+
if (this.isFocused) this.startPositioning()
|
|
381
|
+
})
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.applyBasePanelStyles(floatingEl, referenceEl, context)
|
|
318
386
|
|
|
319
387
|
const middleware = [
|
|
320
388
|
offset(-1),
|
|
@@ -332,7 +400,7 @@ export default {
|
|
|
332
400
|
]
|
|
333
401
|
|
|
334
402
|
computePosition(referenceEl, floatingEl, {
|
|
335
|
-
strategy:
|
|
403
|
+
strategy: context.strategy,
|
|
336
404
|
placement,
|
|
337
405
|
middleware,
|
|
338
406
|
}).then(({ x, y, placement, middlewareData }) => {
|
|
@@ -67,7 +67,7 @@ export default {
|
|
|
67
67
|
return this.$_config('activeTab')
|
|
68
68
|
},
|
|
69
69
|
tabParamKey(){
|
|
70
|
-
return 'tab_number'
|
|
70
|
+
return this.$_config('tabParamKey') || 'tab_number'
|
|
71
71
|
},
|
|
72
72
|
selectId(){
|
|
73
73
|
return this.$_config('selectId')
|
|
@@ -317,6 +317,10 @@ export default {
|
|
|
317
317
|
}else if(this.defaultActiveTab !== undefined && this.defaultActiveTab !== null){
|
|
318
318
|
this.activeTab = this.defaultActiveTab
|
|
319
319
|
}
|
|
320
|
+
|
|
321
|
+
if (this.activeTab >= this.elements.length || this.activeTab < 0 || !this.activeTab) {
|
|
322
|
+
this.activeTab = 0
|
|
323
|
+
}
|
|
320
324
|
},
|
|
321
325
|
mounted(){
|
|
322
326
|
this.syncSelect()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
|
|
3
|
+
// Minimal stub for vl-form-field. Real impl is heavy and unrelated to the
|
|
4
|
+
// behaviour we're testing; we just need a transparent wrapper for the slot.
|
|
5
|
+
const VlFormFieldStub = {
|
|
6
|
+
props: ['component', 'errors'],
|
|
7
|
+
template: '<div class="vl-form-field-stub"><slot /></div>',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mount a kompo Field-style Vue component with a synthesized vkompo blob.
|
|
12
|
+
* Use this for any component that mixes in `vue-kompo/js/form/mixins/Field`
|
|
13
|
+
* (or descendants of `BaseElement`) so tests don't have to hand-roll the
|
|
14
|
+
* shape every time.
|
|
15
|
+
*
|
|
16
|
+
* mountKompo(MyField, {
|
|
17
|
+
* config: { mode: 'single', values: [1,2], colors: ['a','b'] },
|
|
18
|
+
* value: null,
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* Reads `wrapper.vm.component.value` to observe value changes after click.
|
|
22
|
+
*/
|
|
23
|
+
export function mountKompo(component, { config = {}, value = null, name = 'test', label = null } = {}) {
|
|
24
|
+
return mount(component, {
|
|
25
|
+
propsData: {
|
|
26
|
+
vkompo: {
|
|
27
|
+
vueComponent: component.name || 'TestComponent',
|
|
28
|
+
config,
|
|
29
|
+
value,
|
|
30
|
+
name,
|
|
31
|
+
label,
|
|
32
|
+
_kid: 'k-test',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
stubs: {
|
|
36
|
+
'vl-form-field': VlFormFieldStub,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Test setup for components that use vue-kompo's Field mixin chain.
|
|
2
|
+
// The kompo runtime expects `_` (lodash) and `$kompo` to be available globally.
|
|
3
|
+
import _ from 'lodash'
|
|
4
|
+
import Vue from 'vue'
|
|
5
|
+
|
|
6
|
+
// Lodash is referenced as a bare global (`_`) inside vue-kompo mixins.
|
|
7
|
+
globalThis._ = _
|
|
8
|
+
// vue-kompo's KompoHelper module references `Vue` as a bare global at import time.
|
|
9
|
+
globalThis.Vue = Vue
|
|
10
|
+
|
|
11
|
+
// Stub the kompo bus that Field's $_changeAction reaches into.
|
|
12
|
+
// Tests don't exercise the real form/error pipeline, so the methods are no-ops.
|
|
13
|
+
Vue.prototype.$kompo = {
|
|
14
|
+
vlUpdateErrorState() {},
|
|
15
|
+
vlSubmit() {},
|
|
16
|
+
vlToggle() {},
|
|
17
|
+
vlEmitFrom() {},
|
|
18
|
+
vlHybridFilter() {},
|
|
19
|
+
vlJsInstantFilter() {},
|
|
20
|
+
vlSetFieldValue() {},
|
|
21
|
+
events: {
|
|
22
|
+
$on() {},
|
|
23
|
+
$off() {},
|
|
24
|
+
},
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "js-kompo-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.30",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "app.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "vitest"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [],
|
|
10
10
|
"author": "xkairo",
|
|
@@ -27,13 +27,17 @@
|
|
|
27
27
|
"vue-qrcode-reader": "^3.1.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"@vitejs/plugin-vue2": "^2.3.1",
|
|
31
|
+
"@vue/test-utils": "^1.3.6",
|
|
30
32
|
"autoprefixer": "^10.4.7",
|
|
31
33
|
"axios": "^0.21",
|
|
34
|
+
"jsdom": "^24.0.0",
|
|
32
35
|
"laravel-mix": "^6.0.49",
|
|
33
36
|
"lodash": "^4.17.19",
|
|
34
37
|
"postcss": "^8.4.14",
|
|
35
38
|
"sass": "^1.83.0",
|
|
36
39
|
"tailwindcss": "^3.1.6",
|
|
40
|
+
"vitest": "^1.6.0",
|
|
37
41
|
"vue-kompo": "^4.0.3"
|
|
38
42
|
}
|
|
39
43
|
}
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
import vue from '@vitejs/plugin-vue2'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [vue()],
|
|
6
|
+
test: {
|
|
7
|
+
environment: 'jsdom',
|
|
8
|
+
include: ['components/**/*.test.js'],
|
|
9
|
+
setupFiles: ['./components/test-setup.js'],
|
|
10
|
+
},
|
|
11
|
+
})
|