webcake-ui-kit 1.0.28 → 1.0.29

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.
@@ -31,6 +31,8 @@
31
31
  </template>
32
32
 
33
33
  <script>
34
+ import { acquire, release } from '../../floating/portal-root.js'
35
+
34
36
  const GAP = 4
35
37
 
36
38
  export default {
@@ -83,7 +85,10 @@ export default {
83
85
  },
84
86
  mounted() {
85
87
  if (typeof document !== 'undefined' && this.$refs.tooltip) {
86
- document.body.appendChild(this.$refs.tooltip)
88
+ this._tooltipPortalRoot = acquire()
89
+ if (this._tooltipPortalRoot) {
90
+ this._tooltipPortalRoot.appendChild(this.$refs.tooltip)
91
+ }
87
92
  }
88
93
  window.addEventListener('scroll', this.onScrollOrResize, true)
89
94
  window.addEventListener('resize', this.onScrollOrResize)
@@ -138,8 +143,12 @@ export default {
138
143
  cleanup() {
139
144
  window.removeEventListener('scroll', this.onScrollOrResize, true)
140
145
  window.removeEventListener('resize', this.onScrollOrResize)
141
- if (this.$refs.tooltip && this.$refs.tooltip.parentNode === document.body) {
142
- document.body.removeChild(this.$refs.tooltip)
146
+ if (this.$refs.tooltip && this.$refs.tooltip.parentNode) {
147
+ this.$refs.tooltip.parentNode.removeChild(this.$refs.tooltip)
148
+ }
149
+ if (this._tooltipPortalRoot) {
150
+ release()
151
+ this._tooltipPortalRoot = null
143
152
  }
144
153
  }
145
154
  }
@@ -0,0 +1,47 @@
1
+ // Listens for pointerdown anywhere in the document and calls the component's
2
+ // _coOnOutside() if the event target is not inside either the trigger or the
3
+ // floating element. Uses pointerdown (not mousedown) so it covers touch + pen
4
+ // + mouse and fires before focus moves.
5
+
6
+ export default {
7
+ data() {
8
+ return { coAttached: false }
9
+ },
10
+ methods: {
11
+ _coSetElements(trigger, floating) {
12
+ this._coTriggerEl = trigger
13
+ this._coFloatEl = floating
14
+ },
15
+ _coAttach() {
16
+ if (typeof document === 'undefined' || this.coAttached) return
17
+ this._coHandler = e => {
18
+ const trigger = this._coTriggerEl
19
+ const floating = this._coFloatEl
20
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : []
21
+ const target = e.target
22
+ const insideTrigger =
23
+ trigger && (path.indexOf(trigger) !== -1 || (trigger.contains && trigger.contains(target)))
24
+ const insideFloat =
25
+ floating && (path.indexOf(floating) !== -1 || (floating.contains && floating.contains(target)))
26
+ if (!insideTrigger && !insideFloat) this._coOnOutside(e)
27
+ }
28
+ document.addEventListener('pointerdown', this._coHandler, true)
29
+ this.coAttached = true
30
+ },
31
+ _coDetach() {
32
+ if (typeof document === 'undefined' || !this.coAttached) return
33
+ document.removeEventListener('pointerdown', this._coHandler, true)
34
+ this._coHandler = null
35
+ this.coAttached = false
36
+ },
37
+ _coOnOutside() {
38
+ // Override in component.
39
+ }
40
+ },
41
+ beforeDestroy() {
42
+ this._coDetach()
43
+ },
44
+ beforeUnmount() {
45
+ this._coDetach()
46
+ }
47
+ }
@@ -0,0 +1,64 @@
1
+ // Pure positioning function — no DOM mutation, no side effects.
2
+ // Inputs are DOMRect-shaped objects; output is { top, left, placement } in
3
+ // viewport coordinates suitable for `position: fixed`.
4
+
5
+ function parsePlacement(p) {
6
+ const parts = String(p || 'bottom-start').split('-')
7
+ const side = ['top', 'bottom', 'left', 'right'].indexOf(parts[0]) === -1 ? 'bottom' : parts[0]
8
+ const align = ['start', 'end'].indexOf(parts[1]) === -1 ? 'center' : parts[1]
9
+ return { side, align }
10
+ }
11
+
12
+ function alignCross(refRect, floatRect, align, axis) {
13
+ if (axis === 'horizontal') {
14
+ if (align === 'start') return refRect.left
15
+ if (align === 'end') return refRect.right - floatRect.width
16
+ return refRect.left + (refRect.width - floatRect.width) / 2
17
+ }
18
+ if (align === 'start') return refRect.top
19
+ if (align === 'end') return refRect.bottom - floatRect.height
20
+ return refRect.top + (refRect.height - floatRect.height) / 2
21
+ }
22
+
23
+ export function computePosition(refRect, floatRect, placement, options) {
24
+ const opts = options || {}
25
+ const offset = typeof opts.offset === 'number' ? opts.offset : 4
26
+ const padding = typeof opts.padding === 'number' ? opts.padding : 8
27
+ const vw = opts.viewportWidth || (typeof window !== 'undefined' ? window.innerWidth : 1024)
28
+ const vh = opts.viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 768)
29
+
30
+ let { side, align } = parsePlacement(placement)
31
+
32
+ // Flip when the requested side has no room and the opposite does.
33
+ const fitsTop = refRect.top - floatRect.height - offset >= padding
34
+ const fitsBottom = refRect.bottom + floatRect.height + offset <= vh - padding
35
+ const fitsLeft = refRect.left - floatRect.width - offset >= padding
36
+ const fitsRight = refRect.right + floatRect.width + offset <= vw - padding
37
+
38
+ if (side === 'top' && !fitsTop && fitsBottom) side = 'bottom'
39
+ else if (side === 'bottom' && !fitsBottom && fitsTop) side = 'top'
40
+ else if (side === 'left' && !fitsLeft && fitsRight) side = 'right'
41
+ else if (side === 'right' && !fitsRight && fitsLeft) side = 'left'
42
+
43
+ let top = 0
44
+ let left = 0
45
+
46
+ if (side === 'top') {
47
+ top = refRect.top - floatRect.height - offset
48
+ left = alignCross(refRect, floatRect, align, 'horizontal')
49
+ } else if (side === 'bottom') {
50
+ top = refRect.bottom + offset
51
+ left = alignCross(refRect, floatRect, align, 'horizontal')
52
+ } else if (side === 'left') {
53
+ left = refRect.left - floatRect.width - offset
54
+ top = alignCross(refRect, floatRect, align, 'vertical')
55
+ } else {
56
+ left = refRect.right + offset
57
+ top = alignCross(refRect, floatRect, align, 'vertical')
58
+ }
59
+
60
+ left = Math.max(padding, Math.min(left, vw - floatRect.width - padding))
61
+ top = Math.max(padding, Math.min(top, vh - floatRect.height - padding))
62
+
63
+ return { top, left, placement: align === 'center' ? side : side + '-' + align }
64
+ }
@@ -0,0 +1,35 @@
1
+ // Save the currently focused element on open, restore it on close.
2
+ // This is RESTORE only — there is no focus trap. Menus and dropdowns should
3
+ // not trap focus (closing on Tab is the expected behavior). Dialogs trap;
4
+ // menus don't. Don't add a trap "for consistency" — they are different
5
+ // patterns by design.
6
+
7
+ export default {
8
+ data() {
9
+ return { fmRestoreTarget: null }
10
+ },
11
+ methods: {
12
+ _fmCapture() {
13
+ if (typeof document === 'undefined') return
14
+ this.fmRestoreTarget = document.activeElement
15
+ },
16
+ _fmFocus(el) {
17
+ if (el && typeof el.focus === 'function') {
18
+ el.focus()
19
+ }
20
+ },
21
+ _fmRestore() {
22
+ const t = this.fmRestoreTarget
23
+ this.fmRestoreTarget = null
24
+ if (
25
+ t &&
26
+ typeof t.focus === 'function' &&
27
+ typeof document !== 'undefined' &&
28
+ document.contains &&
29
+ document.contains(t)
30
+ ) {
31
+ t.focus()
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,92 @@
1
+ // Keyboard navigation for menu-like floating layers. Items are discovered by
2
+ // querying the floating element's DOM for [data-wk-menu-item]:not([data-disabled])
3
+ // at the moment a key is pressed — this avoids registration-array bugs when
4
+ // items are added/removed via v-if. kmActiveIndex is clamped before every use.
5
+
6
+ const KEYS = {
7
+ ArrowDown: 'down',
8
+ Down: 'down',
9
+ ArrowUp: 'up',
10
+ Up: 'up',
11
+ Home: 'home',
12
+ End: 'end',
13
+ Escape: 'escape',
14
+ Esc: 'escape',
15
+ Enter: 'enter',
16
+ ' ': 'space',
17
+ Spacebar: 'space',
18
+ Tab: 'tab'
19
+ }
20
+
21
+ export default {
22
+ data() {
23
+ return { kmActiveIndex: -1 }
24
+ },
25
+ methods: {
26
+ _kmSetFloatEl(el) {
27
+ this._kmFloatEl = el
28
+ },
29
+ _kmListItems() {
30
+ const float = this._kmFloatEl
31
+ if (!float || typeof float.querySelectorAll !== 'function') return []
32
+ const nodes = float.querySelectorAll('[data-wk-menu-item]:not([data-disabled])')
33
+ const arr = []
34
+ for (let i = 0; i < nodes.length; i++) arr.push(nodes[i])
35
+ return arr
36
+ },
37
+ _kmReset() {
38
+ this.kmActiveIndex = -1
39
+ },
40
+ _kmOnKeydown(e) {
41
+ const k = KEYS[e.key]
42
+ if (!k) return
43
+ if (k === 'escape') {
44
+ e.preventDefault()
45
+ this._kmOnEscape(e)
46
+ return
47
+ }
48
+ if (k === 'tab') {
49
+ this._kmOnTab(e)
50
+ return
51
+ }
52
+ const items = this._kmListItems()
53
+ if (!items.length) return
54
+ let idx = this.kmActiveIndex
55
+ if (k === 'down') {
56
+ idx = idx < 0 ? 0 : (idx + 1) % items.length
57
+ e.preventDefault()
58
+ } else if (k === 'up') {
59
+ idx = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length
60
+ e.preventDefault()
61
+ } else if (k === 'home') {
62
+ idx = 0
63
+ e.preventDefault()
64
+ } else if (k === 'end') {
65
+ idx = items.length - 1
66
+ e.preventDefault()
67
+ } else if (k === 'enter' || k === 'space') {
68
+ if (idx >= 0 && idx < items.length) {
69
+ e.preventDefault()
70
+ items[idx].click()
71
+ }
72
+ return
73
+ }
74
+ idx = Math.max(0, Math.min(idx, items.length - 1))
75
+ this.kmActiveIndex = idx
76
+ this._kmFocusActive()
77
+ },
78
+ _kmFocusActive() {
79
+ const items = this._kmListItems()
80
+ const idx = this.kmActiveIndex
81
+ if (idx >= 0 && idx < items.length && items[idx] && typeof items[idx].focus === 'function') {
82
+ items[idx].focus()
83
+ }
84
+ },
85
+ _kmOnEscape() {
86
+ // Override in component.
87
+ },
88
+ _kmOnTab() {
89
+ // Override in component.
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,22 @@
1
+ // Monotonic z-index counter shared across every floating component.
2
+ // Each opening of a Menu / Dropdown / Select calls nextZIndex() and applies
3
+ // the result inline. Closing does NOT decrement — keeping the counter
4
+ // monotonic avoids fights between concurrent opens (matches Radix UI's
5
+ // approach).
6
+
7
+ const BASE = 1000
8
+ let _counter = BASE
9
+
10
+ export function nextZIndex() {
11
+ _counter++
12
+ return _counter
13
+ }
14
+
15
+ export function currentZIndex() {
16
+ return _counter
17
+ }
18
+
19
+ // Test helper — reset between specs.
20
+ export function _reset() {
21
+ _counter = BASE
22
+ }
@@ -0,0 +1,45 @@
1
+ // Moves an element into the shared portal root on demand. Refcounts acquire/
2
+ // release so the portal root is cleaned up when nothing is using it. Used by
3
+ // Menu; reusable by future Popover/Tooltip/ContextMenu migrations.
4
+ //
5
+ // Non-reactive instance refs use leading underscore so they don't enter Vue's
6
+ // reactivity system. Reactive data is namespaced with the `pmHost` prefix to
7
+ // avoid collisions with other mixins.
8
+
9
+ import { acquire, release } from './portal-root.js'
10
+
11
+ export default {
12
+ data() {
13
+ return { pmHostMounted: false }
14
+ },
15
+ methods: {
16
+ _pmHostMount(el) {
17
+ if (!el || typeof document === 'undefined') return
18
+ const root = acquire()
19
+ if (!root) return
20
+ if (el.parentNode !== root) root.appendChild(el)
21
+ this._pmHostAcquired = true
22
+ this.pmHostMounted = true
23
+ },
24
+ _pmHostUnmount(el) {
25
+ if (el && el.parentNode) el.parentNode.removeChild(el)
26
+ if (this._pmHostAcquired) {
27
+ release()
28
+ this._pmHostAcquired = false
29
+ }
30
+ this.pmHostMounted = false
31
+ }
32
+ },
33
+ beforeDestroy() {
34
+ if (this._pmHostAcquired) {
35
+ release()
36
+ this._pmHostAcquired = false
37
+ }
38
+ },
39
+ beforeUnmount() {
40
+ if (this._pmHostAcquired) {
41
+ release()
42
+ this._pmHostAcquired = false
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,44 @@
1
+ // Shared portal root for floating components (Menu, Dropdown, Select, and any
2
+ // future Popover/Tooltip/ContextMenu/Combobox). All access is lazy and inside
3
+ // functions — never at module top level — so SSR consumers don't blow up on
4
+ // import.
5
+
6
+ let _root = null
7
+ let _refCount = 0
8
+
9
+ export function ensureRoot() {
10
+ if (typeof document === 'undefined') return null
11
+ if (_root && document.body && document.body.contains(_root)) return _root
12
+ _root = document.createElement('div')
13
+ _root.id = 'wk-portal-root'
14
+ _root.setAttribute('data-wk-portal-root', '')
15
+ // The root itself takes no space; children position themselves via fixed.
16
+ _root.style.position = 'absolute'
17
+ _root.style.top = '0'
18
+ _root.style.left = '0'
19
+ _root.style.width = '0'
20
+ _root.style.height = '0'
21
+ document.body.appendChild(_root)
22
+ return _root
23
+ }
24
+
25
+ export function acquire() {
26
+ const root = ensureRoot()
27
+ if (root) _refCount++
28
+ return root
29
+ }
30
+
31
+ export function release() {
32
+ if (_refCount > 0) _refCount--
33
+ if (_refCount === 0 && _root && _root.parentNode) {
34
+ _root.parentNode.removeChild(_root)
35
+ _root = null
36
+ }
37
+ }
38
+
39
+ // Test helper — reset module state between specs.
40
+ export function _reset() {
41
+ if (_root && _root.parentNode) _root.parentNode.removeChild(_root)
42
+ _root = null
43
+ _refCount = 0
44
+ }
@@ -0,0 +1,74 @@
1
+ // Tracks a reference element and updates a floating element's position
2
+ // whenever either resizes or the viewport scrolls. Uses ResizeObserver +
3
+ // a single passive-capture scroll listener throttled via requestAnimationFrame
4
+ // so the work stays cheap even on long-scroll containers.
5
+
6
+ import { computePosition } from './compute-position.js'
7
+
8
+ export default {
9
+ data() {
10
+ return { pmTop: 0, pmLeft: 0, pmResolvedPlacement: '', pmAnchorWidth: 0 }
11
+ },
12
+ methods: {
13
+ _pmAttach(refEl, floatEl) {
14
+ if (typeof window === 'undefined') return
15
+ this._pmRefEl = refEl
16
+ this._pmFloatEl = floatEl
17
+ this._pmTick = () => {
18
+ if (this._pmRaf) return
19
+ this._pmRaf = window.requestAnimationFrame(() => {
20
+ this._pmRaf = null
21
+ this._pmUpdate()
22
+ })
23
+ }
24
+ window.addEventListener('scroll', this._pmTick, { passive: true, capture: true })
25
+ window.addEventListener('resize', this._pmTick)
26
+ if (typeof window.ResizeObserver === 'function') {
27
+ this._pmRO = new window.ResizeObserver(this._pmTick)
28
+ if (refEl) this._pmRO.observe(refEl)
29
+ if (floatEl) this._pmRO.observe(floatEl)
30
+ }
31
+ this._pmUpdate()
32
+ },
33
+ _pmDetach() {
34
+ if (typeof window === 'undefined') return
35
+ if (this._pmTick) {
36
+ window.removeEventListener('scroll', this._pmTick, true)
37
+ window.removeEventListener('resize', this._pmTick)
38
+ }
39
+ if (this._pmRaf) {
40
+ window.cancelAnimationFrame(this._pmRaf)
41
+ this._pmRaf = null
42
+ }
43
+ if (this._pmRO) {
44
+ this._pmRO.disconnect()
45
+ this._pmRO = null
46
+ }
47
+ this._pmRefEl = null
48
+ this._pmFloatEl = null
49
+ this._pmTick = null
50
+ },
51
+ _pmUpdate() {
52
+ const ref = this._pmRefEl
53
+ const float = this._pmFloatEl
54
+ if (!ref || !float || typeof ref.getBoundingClientRect !== 'function') return
55
+ const placement = this.placement || 'bottom-start'
56
+ const offset = typeof this.offset === 'number' ? this.offset : 4
57
+ const r = ref.getBoundingClientRect()
58
+ // Use offsetWidth/offsetHeight (pre-transform layout size) so that CSS enter/leave
59
+ // animations (e.g. scale(0.96)) don't skew the dimension used in top/left placement math.
60
+ const f = { width: float.offsetWidth, height: float.offsetHeight }
61
+ const result = computePosition(r, f, placement, { offset })
62
+ this.pmTop = result.top
63
+ this.pmLeft = result.left
64
+ this.pmResolvedPlacement = result.placement
65
+ this.pmAnchorWidth = r.width
66
+ }
67
+ },
68
+ beforeDestroy() {
69
+ this._pmDetach()
70
+ },
71
+ beforeUnmount() {
72
+ this._pmDetach()
73
+ }
74
+ }
package/src/index.js CHANGED
@@ -11,10 +11,12 @@ export { default as WkCheckbox } from './components/checkbox/Checkbox.vue'
11
11
  export { default as WkCheckboxGroup } from './components/checkbox-group/CheckboxGroup.vue'
12
12
  export { default as WkDialog } from './components/dialog/Dialog.vue'
13
13
  export { default as WkDivider } from './components/divider/Divider.vue'
14
+ export { default as WkDropdown } from './components/dropdown/Dropdown.vue'
14
15
  export { default as WkEmpty } from './components/empty/Empty.vue'
15
16
  export { default as WkEmptyIcon } from './components/empty-icon/EmptyIcon.vue'
16
17
  export { default as WkField } from './components/field/Field.vue'
17
18
  export { default as WkInput } from './components/input/Input.vue'
19
+ export { default as WkMenu } from './components/menu/Menu.vue'
18
20
  export { default as WkPagination } from './components/pagination/Pagination.vue'
19
21
  export { default as WkRadio } from './components/radio/Radio.vue'
20
22
  export { default as WkRadioGroup } from './components/radio-group/RadioGroup.vue'