@featherk/composables 0.15.3 → 0.15.4
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/docs-manifest.js +24 -24
- package/dist/featherk-composables.es.js +152 -144
- package/dist/featherk-composables.es.js.map +1 -1
- package/dist/featherk-composables.umd.js +1 -1
- package/dist/featherk-composables.umd.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/trap/usePopupTrap.d.ts +11 -3
- package/docs/menu/usePopupMenu.md +10 -3
- package/docs/menu/usePopupMenuGridOrchestration.md +2 -1
- package/docs/time/useMaskedTimeInput.md +59 -42
- package/docs/trap/usePopupTrap.md +294 -38
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,5 @@ export { useMaskedDateRangeInput } from "./range";
|
|
|
13
13
|
export type { RangeChangePayload } from "./range";
|
|
14
14
|
export { useMaskedTimeInput } from "./time";
|
|
15
15
|
export type { ChangePayload as TimeChangePayload } from "./time";
|
|
16
|
-
export { POPUP_MENU_TRAP_OPTIONS, usePopupTrap } from "./trap";
|
|
16
|
+
export { DIALOG_TRAP_OPTIONS, POPUP_MENU_TRAP_OPTIONS, usePopupTrap, } from "./trap";
|
|
17
|
+
export type { PopupTrapOutsideClickOptions } from "./trap";
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { type Ref } from "vue";
|
|
2
2
|
import { useFocusTrap } from "@vueuse/integrations/useFocusTrap";
|
|
3
|
+
import { type OnClickOutsideOptions } from "@vueuse/core";
|
|
3
4
|
export type CloseReason = "escape" | "outside";
|
|
5
|
+
export type PopupTrapOutsideClickOptions = Pick<OnClickOutsideOptions, "ignore">;
|
|
4
6
|
export type InitialFocus = string | ((root: HTMLElement) => HTMLElement | null | undefined);
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
8
|
+
* Dismissal and focus-restoration options for pairing with `usePopupMenu`.
|
|
7
9
|
*
|
|
8
|
-
* `usePopupMenu` owns menu-close focus restoration.
|
|
9
|
-
*
|
|
10
|
+
* `usePopupMenu` owns outside dismissal and menu-close focus restoration.
|
|
11
|
+
* These settings disable the trap's outside listener and both competing
|
|
12
|
+
* restoration paths in `usePopupTrap` and the underlying
|
|
10
13
|
* `focus-trap` implementation. Spread this preset last so those integration
|
|
11
14
|
* settings take precedence.
|
|
12
15
|
*/
|
|
13
16
|
export declare const POPUP_MENU_TRAP_OPTIONS: {
|
|
17
|
+
readonly outsideClick: false;
|
|
14
18
|
readonly returnFocusToTrigger: false;
|
|
15
19
|
readonly focusTrapOptions: {
|
|
16
20
|
readonly returnFocusOnDeactivate: false;
|
|
17
21
|
};
|
|
18
22
|
};
|
|
23
|
+
export declare const DIALOG_TRAP_OPTIONS: {
|
|
24
|
+
readonly outsideClick: false;
|
|
25
|
+
};
|
|
19
26
|
export declare function usePopupTrap(opts: {
|
|
20
27
|
isOpen: Ref<boolean>;
|
|
21
28
|
onRequestClose?: (reason: CloseReason, ev?: Event) => void;
|
|
@@ -23,6 +30,7 @@ export declare function usePopupTrap(opts: {
|
|
|
23
30
|
triggerEl?: Ref<HTMLElement | null>;
|
|
24
31
|
resolvePopupEl?: () => HTMLElement | null;
|
|
25
32
|
initialFocus?: InitialFocus;
|
|
33
|
+
outsideClick?: false | PopupTrapOutsideClickOptions;
|
|
26
34
|
focusTrapOptions?: Parameters<typeof useFocusTrap>[1];
|
|
27
35
|
returnFocusToTrigger?: boolean;
|
|
28
36
|
}): {
|
|
@@ -268,7 +268,7 @@ See [Focus and Exclusivity](https://github.com/NantHealth/featherk/blob/integrat
|
|
|
268
268
|
`usePopupMenu` deliberately does not trap focus (see the intro above) - a real menu should let `Tab` move on to the next element in the page, not cycle inside it. If a specific popup genuinely needs `Tab` trapped (behaving more like a modal than a menu), compose `usePopupTrap` alongside it rather than fighting its own close/focus-restoration logic:
|
|
269
269
|
|
|
270
270
|
1. Pass the same `isOpen` and the resolved trigger element (`triggerEl`) that you gave `usePopupMenu`.
|
|
271
|
-
2.
|
|
271
|
+
2. Spread `POPUP_MENU_TRAP_OPTIONS` and omit `onRequestClose` entirely. The preset includes `outsideClick: false`, preventing a competing outside-click listener; the trap's independent Escape listener can still record a reason, but dismissal stays with the menu's handlers. Passing `onRequestClose` here would introduce another close-request owner.
|
|
272
272
|
3. Set `initialFocus: () => document.activeElement`. `usePopupMenu` already focuses the first enabled menu item on open; this preserves that instead of refocusing a possibly different element.
|
|
273
273
|
4. Set `returnFocusToTrigger: false`. This only disables `usePopupTrap`'s own hand-rolled Escape-focus logic.
|
|
274
274
|
5. **Also set `focusTrapOptions: { returnFocusOnDeactivate: false }`.** This is a separate, easy-to-miss setting: the underlying `focus-trap` library has its own independent `returnFocusOnDeactivate` option that defaults to `true` and fires on every `deactivate()`, restoring focus to whatever it captured as "previously focused" - completely bypassing step 4 and racing `usePopupMenu`'s own (correct) restoration target. Skipping this leaves focus landing on the wrong element after close, even though everything else is wired correctly.
|
|
@@ -293,8 +293,9 @@ usePopupTrap({
|
|
|
293
293
|
```
|
|
294
294
|
|
|
295
295
|
`POPUP_MENU_TRAP_OPTIONS` is an optional convenience preset for this pairing.
|
|
296
|
-
It disables the trap's competing focus restoration paths
|
|
297
|
-
remains the sole owner of
|
|
296
|
+
It disables the trap's outside listener and competing focus restoration paths
|
|
297
|
+
so `usePopupMenu` remains the sole owner of outside dismissal and focus
|
|
298
|
+
restoration. `initialFocus` stays explicit
|
|
298
299
|
because it should preserve the focus behavior chosen by each menu. Spread the
|
|
299
300
|
preset last in the options object so these required integration settings win
|
|
300
301
|
over conflicting trap options:
|
|
@@ -307,6 +308,12 @@ usePopupTrap({
|
|
|
307
308
|
});
|
|
308
309
|
```
|
|
309
310
|
|
|
311
|
+
The preset does not change the menu's own trigger-ignore or Escape policies.
|
|
312
|
+
It is intended for a companion trap, not standalone popups. Previously it
|
|
313
|
+
disabled focus restoration only; callers deliberately relying on the trap's
|
|
314
|
+
outside callback can place `outsideClick: {}` after the preset to retain that
|
|
315
|
+
behavior, accepting responsibility for dismissal ownership.
|
|
316
|
+
|
|
310
317
|
## API
|
|
311
318
|
|
|
312
319
|
### `usePopupMenu(options)`
|
|
@@ -485,7 +485,8 @@ this composite-id orchestration.
|
|
|
485
485
|
|
|
486
486
|
Neither `rowMenu` nor `buttonMenu` above traps `Tab` inside its Popup - that's
|
|
487
487
|
intentional (see `usePopupMenu.md`'s intro). If a specific shared popup needs
|
|
488
|
-
it anyway, pair it with `usePopupTrap` using the same registry
|
|
488
|
+
it anyway, pair it with `usePopupTrap` using the same registry and spread
|
|
489
|
+
`POPUP_MENU_TRAP_OPTIONS` to delegate outside dismissal to the menu and disable
|
|
489
490
|
both its own Escape-focus logic and the underlying `focus-trap` library's
|
|
490
491
|
independent `returnFocusOnDeactivate` default, so `usePopupMenu` remains the
|
|
491
492
|
sole owner of close/focus-restoration decisions. Both shared Popups above stay
|
|
@@ -14,7 +14,51 @@ This composable preserves the user's raw input: it does not coerce or rewrite wh
|
|
|
14
14
|
|
|
15
15
|
## Quick Start
|
|
16
16
|
|
|
17
|
+
1. Create the time value, popup visibility, and DOM-wrapper refs.
|
|
18
|
+
2. Configure `useMaskedTimeInput` with a unique input ID and shared value.
|
|
19
|
+
3. Connect `usePopupTrap` to the time popup's state and wrapper.
|
|
20
|
+
4. Replace the built-in input with a wrapped `MaskedTextBox`; wire input handlers and TimePicker popup actions.
|
|
21
|
+
|
|
17
22
|
```vue
|
|
23
|
+
<template>
|
|
24
|
+
<!-- Step 4: replace TimePicker's input through dateInput, not timeInput. -->
|
|
25
|
+
<div ref="timePickerRef">
|
|
26
|
+
<TimePicker
|
|
27
|
+
v-model="selectedTime"
|
|
28
|
+
dateInput="masked"
|
|
29
|
+
:format="'hh:mm a'"
|
|
30
|
+
:min="minTime"
|
|
31
|
+
:max="maxTime"
|
|
32
|
+
:valid="isValid"
|
|
33
|
+
:validationMessage="validationMessage"
|
|
34
|
+
:show="showPicker"
|
|
35
|
+
@change="masked.handleChange"
|
|
36
|
+
@open="showPicker = true"
|
|
37
|
+
@close="showPicker = false"
|
|
38
|
+
>
|
|
39
|
+
<template #masked="{}">
|
|
40
|
+
<div class="custom-time-input">
|
|
41
|
+
<MaskedTextBox
|
|
42
|
+
id="startTime"
|
|
43
|
+
:mask="'Hh:Mm Aa'"
|
|
44
|
+
:rules="rules"
|
|
45
|
+
:value="raw"
|
|
46
|
+
:placeholder="placeholder"
|
|
47
|
+
:showClearButton="false"
|
|
48
|
+
@change="masked.handleChange"
|
|
49
|
+
@keydown="masked.handleKeyDown"
|
|
50
|
+
@keyup="masked.handleKeyUp"
|
|
51
|
+
@click="masked.handleClick"
|
|
52
|
+
@wheel="masked.handleWheel"
|
|
53
|
+
@blur="masked.handleBlur"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
</TimePicker>
|
|
58
|
+
</div>
|
|
59
|
+
<Error for="startTime">{{ validationMessage }}</Error>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
18
62
|
<script setup lang="ts">
|
|
19
63
|
import { ref } from "vue";
|
|
20
64
|
import { TimePicker } from "@progress/kendo-vue-dateinputs";
|
|
@@ -23,7 +67,7 @@ import { Error } from "@progress/kendo-vue-labels";
|
|
|
23
67
|
import { useMaskedTimeInput } from "@featherk/composables/time";
|
|
24
68
|
import { usePopupTrap } from "@featherk/composables/trap";
|
|
25
69
|
|
|
26
|
-
//
|
|
70
|
+
// Step 1: shared model for this TimePicker and its own controller.
|
|
27
71
|
const selectedTime = ref<Date | null | undefined>();
|
|
28
72
|
const showPicker = ref(false);
|
|
29
73
|
const timePickerRef = ref<HTMLElement | null>(null);
|
|
@@ -32,6 +76,7 @@ const timePickerRef = ref<HTMLElement | null>(null);
|
|
|
32
76
|
const minTime = new Date(new Date().setHours(8, 0, 0, 0)); // 8:00 AM
|
|
33
77
|
const maxTime = new Date(new Date().setHours(17, 0, 0, 0)); // 5:00 PM
|
|
34
78
|
|
|
79
|
+
// Step 2: synchronize typed values and popup selections.
|
|
35
80
|
const masked = useMaskedTimeInput({
|
|
36
81
|
id: "startTime",
|
|
37
82
|
onChange: ({ value }) => { selectedTime.value = value ?? undefined; },
|
|
@@ -40,7 +85,6 @@ const masked = useMaskedTimeInput({
|
|
|
40
85
|
minuteStep: 5,
|
|
41
86
|
min: minTime,
|
|
42
87
|
max: maxTime,
|
|
43
|
-
required: true,
|
|
44
88
|
});
|
|
45
89
|
|
|
46
90
|
// Destructure for simpler template bindings
|
|
@@ -57,7 +101,7 @@ const {
|
|
|
57
101
|
debugLines,
|
|
58
102
|
} = masked;
|
|
59
103
|
|
|
60
|
-
//
|
|
104
|
+
// Step 3: trap the popup using its DOM-wrapper ref.
|
|
61
105
|
usePopupTrap({
|
|
62
106
|
isOpen: showPicker,
|
|
63
107
|
onRequestClose: () => (showPicker.value = false),
|
|
@@ -65,47 +109,20 @@ usePopupTrap({
|
|
|
65
109
|
popupSelector: [".k-timeselector", ".k-popup"],
|
|
66
110
|
});
|
|
67
111
|
</script>
|
|
68
|
-
|
|
69
|
-
<template>
|
|
70
|
-
<div ref="timePickerRef">
|
|
71
|
-
<TimePicker
|
|
72
|
-
v-model="selectedTime"
|
|
73
|
-
dateInput="masked"
|
|
74
|
-
:format="'hh:mm a'"
|
|
75
|
-
:show="showPicker"
|
|
76
|
-
@open="showPicker = true"
|
|
77
|
-
@close="showPicker = false"
|
|
78
|
-
:style="{ width: 'fit-content' }"
|
|
79
|
-
>
|
|
80
|
-
<template #masked="{ props }">
|
|
81
|
-
<MaskedTextBox
|
|
82
|
-
id="startTime"
|
|
83
|
-
:mask="'Hh:Mm Aa'"
|
|
84
|
-
:rules="rules"
|
|
85
|
-
:value="raw"
|
|
86
|
-
:placeholder="placeholder"
|
|
87
|
-
:showClearButton="false"
|
|
88
|
-
@change="masked.handleChange"
|
|
89
|
-
@keydown="masked.handleKeyDown"
|
|
90
|
-
@keyup="masked.handleKeyUp"
|
|
91
|
-
@click="masked.handleClick"
|
|
92
|
-
@wheel="masked.handleWheel"
|
|
93
|
-
@blur="masked.handleBlur"
|
|
94
|
-
/>
|
|
95
|
-
</template>
|
|
96
|
-
</TimePicker>
|
|
97
|
-
</div>
|
|
98
|
-
<Error for="startTime">{{ validationMessage }}</Error>
|
|
99
|
-
|
|
100
|
-
<!-- Optional: show internal debug lines during integration -->
|
|
101
|
-
<div v-if="debugEnabled" style="margin-top: 8px; color: #475467;">
|
|
102
|
-
<div v-for="(row, idx) in debugLines" :key="idx">
|
|
103
|
-
<strong>{{ row[0] }}:</strong> {{ row[1] }}
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
</template>
|
|
107
112
|
```
|
|
108
113
|
|
|
114
|
+
Like the date and date-range examples, this integration uses a custom
|
|
115
|
+
`MaskedTextBox`, not Kendo's built-in date/time input. DatePicker and TimePicker
|
|
116
|
+
both expose the `dateInput` slot; the range example uses a separate
|
|
117
|
+
`MaskedTextBox` with a `Calendar`/`Popup`. Each picker needs its own controller,
|
|
118
|
+
input ID, value, and visibility state. For a picker inside a dialog, apply
|
|
119
|
+
`DIALOG_TRAP_OPTIONS` to the parent dialog trap, not the time popup trap; the
|
|
120
|
+
time popup keeps its own outside dismissal.
|
|
121
|
+
|
|
122
|
+
When enabling `required`, the composable can write an empty string into its
|
|
123
|
+
`externalValue` on blank blur. Keep that validation state separate from the
|
|
124
|
+
TimePicker's model, or normalize its binding to `Date | null | undefined`.
|
|
125
|
+
|
|
109
126
|
### Full Example
|
|
110
127
|
|
|
111
128
|
For a complete integration including a focus trap, store sync, and UI tweaks, see the demo view referenced in this repository.
|
|
@@ -12,15 +12,141 @@ Composable for managing focus trapping and close behavior for popup UIs (e.g., K
|
|
|
12
12
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
1. Create the menu visibility, trigger, and Menu component refs.
|
|
16
|
+
2. Configure `usePopupMenu` to own dismissal and focus restoration.
|
|
17
|
+
3. Add `usePopupTrap` with `POPUP_MENU_TRAP_OPTIONS` for focus containment only.
|
|
18
|
+
4. Bind the trigger, Popup, and Menu to the menu controller's handlers.
|
|
19
|
+
|
|
20
|
+
```vue
|
|
21
|
+
<template>
|
|
22
|
+
<!-- Step 4: route interactions through the menu controller. -->
|
|
23
|
+
<button
|
|
24
|
+
ref="triggerRef"
|
|
25
|
+
type="button"
|
|
26
|
+
aria-haspopup="menu"
|
|
27
|
+
aria-expanded="false"
|
|
28
|
+
@click="popupMenu.handleTriggerClick"
|
|
29
|
+
@keydown="popupMenu.handleTriggerKeydown"
|
|
30
|
+
>
|
|
31
|
+
Actions
|
|
32
|
+
</button>
|
|
33
|
+
<Popup
|
|
34
|
+
:show="open"
|
|
35
|
+
:anchor="'triggerRef'"
|
|
36
|
+
@close="popupMenu.handlePopupClose()"
|
|
37
|
+
>
|
|
38
|
+
<Menu
|
|
39
|
+
ref="menuRef"
|
|
40
|
+
:items="items"
|
|
41
|
+
:vertical="true"
|
|
42
|
+
@keydown.escape="popupMenu.handleMenuEscape"
|
|
43
|
+
@select="popupMenu.handleMenuSelect"
|
|
44
|
+
/>
|
|
45
|
+
</Popup>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<script setup lang="ts">
|
|
49
|
+
import { ref, type ComponentPublicInstance } from "vue";
|
|
50
|
+
import { Popup } from "@progress/kendo-vue-popup";
|
|
51
|
+
import { Menu } from "@progress/kendo-vue-layout";
|
|
52
|
+
import { usePopupMenu } from "@featherk/composables/menu";
|
|
53
|
+
import { POPUP_MENU_TRAP_OPTIONS, usePopupTrap } from "@featherk/composables/trap";
|
|
54
|
+
|
|
55
|
+
// Step 1: create consumer-owned visibility and the DOM/component refs.
|
|
56
|
+
const open = ref(false);
|
|
57
|
+
const triggerRef = ref<HTMLElement | null>(null);
|
|
58
|
+
const menuRef = ref<ComponentPublicInstance | null>(null);
|
|
59
|
+
const items = [{ text: "Edit" }, { text: "Archive" }];
|
|
60
|
+
|
|
61
|
+
// Step 2: the menu owns close requests, ARIA state, and focus restoration.
|
|
62
|
+
const popupMenu = usePopupMenu({
|
|
63
|
+
isOpen: open,
|
|
64
|
+
triggerRef,
|
|
65
|
+
menuRef,
|
|
66
|
+
triggerMode: "button",
|
|
67
|
+
requestShow: () => { open.value = true; },
|
|
68
|
+
requestHide: () => { open.value = false; },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Step 3: preserve menu focus and delegate dismissal through the preset.
|
|
72
|
+
usePopupTrap({
|
|
73
|
+
isOpen: open,
|
|
74
|
+
triggerEl: triggerRef,
|
|
75
|
+
initialFocus: () => document.activeElement as HTMLElement | null,
|
|
76
|
+
...POPUP_MENU_TRAP_OPTIONS,
|
|
77
|
+
});
|
|
78
|
+
</script>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This pairing intentionally traps Tab inside the popup. For ordinary menus that
|
|
82
|
+
let Tab move on, use `usePopupMenu` without the companion trap. The preset
|
|
83
|
+
disables the trap's outside listener and competing focus restoration; outside
|
|
84
|
+
clicks still close through `usePopupMenu`. Do not add `onRequestClose` to this
|
|
85
|
+
trap: the menu's handlers remain the dismissal owner.
|
|
86
|
+
|
|
87
|
+
### DatePicker With a Custom Masked Input
|
|
88
|
+
|
|
89
|
+
1. Create independent visibility, value, input ID, and trigger refs for this picker.
|
|
90
|
+
2. Configure `useMaskedDateInput` to synchronize the value and open the calendar.
|
|
91
|
+
3. Connect `usePopupTrap` to the visibility and trigger refs.
|
|
92
|
+
4. Replace the DatePicker input with a wrapped `MaskedTextBox` and wire its handlers.
|
|
93
|
+
|
|
94
|
+
```vue
|
|
95
|
+
<template>
|
|
96
|
+
<!-- Step 4: use the controller's custom input, not Kendo's built-in DateInput. -->
|
|
97
|
+
<div ref="datePickerRef">
|
|
98
|
+
<DatePicker
|
|
99
|
+
v-model="selectedDate"
|
|
100
|
+
dateInput="masked"
|
|
101
|
+
:valid="isValid"
|
|
102
|
+
:validationMessage="validationMessage"
|
|
103
|
+
:show="open"
|
|
104
|
+
@open="open = true"
|
|
105
|
+
@close="open = false"
|
|
106
|
+
>
|
|
107
|
+
<template #masked="{}">
|
|
108
|
+
<div class="custom-date-input">
|
|
109
|
+
<MaskedTextBox
|
|
110
|
+
id="popup-date"
|
|
111
|
+
:mask="'00/00/0000'"
|
|
112
|
+
:value="raw"
|
|
113
|
+
:placeholder="placeholder"
|
|
114
|
+
:showClearButton="false"
|
|
115
|
+
@change="masked.handleChange"
|
|
116
|
+
@keydown="masked.handleKeyDown"
|
|
117
|
+
@keyup="masked.handleKeyUp"
|
|
118
|
+
@click="masked.handleClick"
|
|
119
|
+
@wheel="masked.handleWheel"
|
|
120
|
+
@blur="masked.handleBlur"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</template>
|
|
124
|
+
</DatePicker>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
16
128
|
<script setup lang="ts">
|
|
17
129
|
import { ref, shallowRef } from "vue";
|
|
18
130
|
import { DatePicker } from "@progress/kendo-vue-dateinputs";
|
|
131
|
+
import { MaskedTextBox } from "@progress/kendo-vue-inputs";
|
|
132
|
+
import { useMaskedDateInput } from "@featherk/composables/date";
|
|
19
133
|
import { usePopupTrap } from "@featherk/composables/trap";
|
|
20
134
|
|
|
135
|
+
// Step 1: create consumer-owned state and the trigger ref.
|
|
21
136
|
const open = ref(false);
|
|
137
|
+
const selectedDate = ref<Date | undefined>();
|
|
22
138
|
const datePickerRef = shallowRef<HTMLElement | null>(null);
|
|
23
139
|
|
|
140
|
+
// Step 2: give this input its own masked controller and value.
|
|
141
|
+
const masked = useMaskedDateInput({
|
|
142
|
+
id: "popup-date",
|
|
143
|
+
externalValue: selectedDate,
|
|
144
|
+
onChange: ({ value }) => { selectedDate.value = value ?? undefined; },
|
|
145
|
+
onShowCalendar: () => { open.value = true; },
|
|
146
|
+
});
|
|
147
|
+
const { raw, placeholder, isValid, validationMessage } = masked;
|
|
148
|
+
|
|
149
|
+
// Step 3: accept the trap's close requests by updating visibility.
|
|
24
150
|
usePopupTrap({
|
|
25
151
|
isOpen: open,
|
|
26
152
|
onRequestClose: () => (open.value = false),
|
|
@@ -28,63 +154,173 @@ usePopupTrap({
|
|
|
28
154
|
// Use defaults: finds .k-popup near the trigger within .k-animation-container
|
|
29
155
|
});
|
|
30
156
|
</script>
|
|
31
|
-
|
|
32
|
-
<template>
|
|
33
|
-
<div ref="datePickerRef">
|
|
34
|
-
<DatePicker v-model="/* your value */" :show="open" @open="open=true" @close="open=false" />
|
|
35
|
-
</div>
|
|
36
|
-
</template>
|
|
37
157
|
```
|
|
38
158
|
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
159
|
+
The picker examples here use a custom `MaskedTextBox`, not Kendo's built-in
|
|
160
|
+
date/time input. `DatePicker` and `TimePicker` both use `dateInput="masked"`
|
|
161
|
+
with a `#masked` slot. Date ranges use a `MaskedTextBox` alongside a separate
|
|
162
|
+
`Calendar`/`Popup`, rather than a date-input slot. Keep input IDs, controller
|
|
163
|
+
instances, values, and visibility refs independent when multiple pickers share
|
|
164
|
+
a page or a dialog.
|
|
165
|
+
|
|
166
|
+
For complete picker wiring, see
|
|
167
|
+
[useMaskedDateInput](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/date/useMaskedDateInput.md),
|
|
168
|
+
[useMaskedDateRangeInput](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/range/useMaskedDateRangeInput.md), and
|
|
169
|
+
[useMaskedTimeInput](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/time/useMaskedTimeInput.md).
|
|
170
|
+
|
|
171
|
+
### Pairing With usePopupMenu
|
|
172
|
+
|
|
173
|
+
See [Quick Start](#quick-start) for the complete pairing. `usePopupMenu` owns
|
|
174
|
+
dismissal and focus restoration; `usePopupTrap` adds focus containment.
|
|
175
|
+
|
|
176
|
+
Spread `POPUP_MENU_TRAP_OPTIONS` last to disable the trap's outside listener
|
|
177
|
+
and both competing focus-restoration paths. No separate `outsideClick: false`
|
|
178
|
+
flag is needed. Keep `initialFocus` explicit to preserve the focus chosen by
|
|
179
|
+
the menu, and omit `onRequestClose` so Escape and dismissal stay with the menu's
|
|
180
|
+
handlers. `allowOutsideClick` already defaults to `true`.
|
|
181
|
+
|
|
182
|
+
This preset is only for traps paired with `usePopupMenu`, not every popup.
|
|
183
|
+
Standalone popups and pickers retain outside dismissal by default.
|
|
184
|
+
|
|
185
|
+
### Dialog With a Teleported Calendar
|
|
186
|
+
|
|
187
|
+
Dialogs should stay open on all outside clicks, including backdrop clicks and
|
|
188
|
+
clicks in a teleported DatePicker calendar. Spread `DIALOG_TRAP_OPTIONS` first
|
|
189
|
+
to disable the dialog trap's outside listener while preserving Escape and focus
|
|
190
|
+
trapping. Close, Cancel, and confirmation buttons continue to update visibility
|
|
191
|
+
through their existing handlers.
|
|
42
192
|
|
|
43
193
|
```ts
|
|
44
|
-
import {
|
|
45
|
-
POPUP_MENU_TRAP_OPTIONS,
|
|
46
|
-
usePopupTrap,
|
|
47
|
-
} from "@featherk/composables/trap";
|
|
194
|
+
import { DIALOG_TRAP_OPTIONS, usePopupTrap } from "@featherk/composables/trap";
|
|
48
195
|
|
|
49
196
|
usePopupTrap({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
197
|
+
...DIALOG_TRAP_OPTIONS,
|
|
198
|
+
isOpen: dialogIsVisible,
|
|
199
|
+
triggerEl: dialogTriggerRef,
|
|
200
|
+
resolvePopupEl: resolveDialogElement,
|
|
201
|
+
onRequestClose: (reason) => {
|
|
202
|
+
if (reason === "escape" && showPicker.value) return;
|
|
203
|
+
dialogIsVisible.value = false;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
usePopupTrap({
|
|
208
|
+
isOpen: showPicker,
|
|
209
|
+
triggerEl: datePickerRef,
|
|
210
|
+
onRequestClose: () => { showPicker.value = false; },
|
|
54
211
|
});
|
|
55
212
|
```
|
|
56
213
|
|
|
57
|
-
|
|
58
|
-
the
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
214
|
+
The parent guard preserves the nested keyboard policy: the first Escape closes
|
|
215
|
+
only the calendar; the next closes the dialog. Focus-trap stacking alone does
|
|
216
|
+
not coordinate FeatherK's independent Escape or outside-click listeners.
|
|
217
|
+
Neither the preset nor `escapeDeactivates: false` disables FeatherK's Escape
|
|
218
|
+
requests. No calendar-specific outside-click ignore list is needed for a dialog.
|
|
62
219
|
|
|
63
|
-
###
|
|
220
|
+
### Dismissible Parent With Related Popups
|
|
221
|
+
|
|
222
|
+
For a parent that intentionally dismisses on outside clicks, ignore its exact
|
|
223
|
+
owned child popup rather than using a broad global calendar selector. Create the
|
|
224
|
+
child trap first so its ref can be passed directly:
|
|
64
225
|
|
|
65
226
|
```ts
|
|
227
|
+
const calendarTrap = usePopupTrap({
|
|
228
|
+
isOpen: showPicker,
|
|
229
|
+
triggerEl: datePickerRef,
|
|
230
|
+
onRequestClose: () => { showPicker.value = false; },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
usePopupTrap({
|
|
234
|
+
isOpen: parentIsVisible,
|
|
235
|
+
resolvePopupEl: resolveParentElement,
|
|
236
|
+
outsideClick: { ignore: [calendarTrap.popupRef] },
|
|
237
|
+
onRequestClose: (reason) => {
|
|
238
|
+
if (reason === "escape" && showPicker.value) return;
|
|
239
|
+
parentIsVisible.value = false;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
VueUse handles ignore matching and pointerdown/click semantics. Element refs can
|
|
245
|
+
start null and later mount or retarget; selectors and reactive/getter ignore
|
|
246
|
+
lists are supported with the installed VueUse types. Ignoring a child affects
|
|
247
|
+
dismissal only: it does not add the child to the parent's focus trap. Teleported
|
|
248
|
+
children still need their own working focus containment and keyboard behavior.
|
|
249
|
+
|
|
250
|
+
### TimePicker With a Custom Masked Input
|
|
251
|
+
|
|
252
|
+
1. Create the time value, visibility, and DOM-wrapper refs.
|
|
253
|
+
2. Configure `useMaskedTimeInput` with its own input ID and value.
|
|
254
|
+
3. Connect `usePopupTrap` using the TimePicker popup selectors.
|
|
255
|
+
4. Render the wrapped `MaskedTextBox` in the `dateInput` slot and wire both input and picker changes.
|
|
256
|
+
|
|
257
|
+
```vue
|
|
258
|
+
<template>
|
|
259
|
+
<!-- Step 4: TimePicker uses dateInput, not timeInput, for its custom input. -->
|
|
260
|
+
<div ref="timePickerRef">
|
|
261
|
+
<TimePicker
|
|
262
|
+
v-model="selectedTime"
|
|
263
|
+
dateInput="masked"
|
|
264
|
+
:format="'hh:mm a'"
|
|
265
|
+
:valid="isValid"
|
|
266
|
+
:validationMessage="validationMessage"
|
|
267
|
+
:show="showPicker"
|
|
268
|
+
@change="masked.handleChange"
|
|
269
|
+
@open="showPicker = true"
|
|
270
|
+
@close="showPicker = false"
|
|
271
|
+
>
|
|
272
|
+
<template #masked="{}">
|
|
273
|
+
<div class="custom-time-input">
|
|
274
|
+
<MaskedTextBox
|
|
275
|
+
id="popup-time"
|
|
276
|
+
:mask="'Hh:Mm Aa'"
|
|
277
|
+
:rules="rules"
|
|
278
|
+
:value="raw"
|
|
279
|
+
:placeholder="placeholder"
|
|
280
|
+
:showClearButton="false"
|
|
281
|
+
@change="masked.handleChange"
|
|
282
|
+
@keydown="masked.handleKeyDown"
|
|
283
|
+
@keyup="masked.handleKeyUp"
|
|
284
|
+
@click="masked.handleClick"
|
|
285
|
+
@wheel="masked.handleWheel"
|
|
286
|
+
@blur="masked.handleBlur"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
</template>
|
|
290
|
+
</TimePicker>
|
|
291
|
+
</div>
|
|
292
|
+
</template>
|
|
293
|
+
|
|
66
294
|
<script setup lang="ts">
|
|
67
|
-
import { ref
|
|
295
|
+
import { ref } from "vue";
|
|
68
296
|
import { TimePicker } from "@progress/kendo-vue-dateinputs";
|
|
297
|
+
import { MaskedTextBox } from "@progress/kendo-vue-inputs";
|
|
298
|
+
import { useMaskedTimeInput } from "@featherk/composables/time";
|
|
69
299
|
import { usePopupTrap } from "@featherk/composables/trap";
|
|
70
300
|
|
|
301
|
+
// Step 1: keep this picker's state separate from other examples.
|
|
302
|
+
const selectedTime = ref<Date | undefined>();
|
|
71
303
|
const showPicker = ref(false);
|
|
72
|
-
const timePickerRef =
|
|
304
|
+
const timePickerRef = ref<HTMLElement | null>(null);
|
|
305
|
+
|
|
306
|
+
// Step 2: synchronize typed input and popup selections through one controller.
|
|
307
|
+
const masked = useMaskedTimeInput({
|
|
308
|
+
id: "popup-time",
|
|
309
|
+
externalValue: selectedTime,
|
|
310
|
+
onChange: ({ value }) => { selectedTime.value = value ?? undefined; },
|
|
311
|
+
onShowPicker: () => { showPicker.value = true; },
|
|
312
|
+
minuteStep: 5,
|
|
313
|
+
});
|
|
314
|
+
const { raw, rules, placeholder, isValid, validationMessage } = masked;
|
|
73
315
|
|
|
316
|
+
// Step 3: trap the popup using its DOM-wrapper ref.
|
|
74
317
|
usePopupTrap({
|
|
75
318
|
isOpen: showPicker,
|
|
76
319
|
onRequestClose: () => (showPicker.value = false),
|
|
77
320
|
triggerEl: timePickerRef,
|
|
78
321
|
popupSelector: [".k-timeselector", ".k-popup"],
|
|
79
|
-
returnFocusToTrigger: false, // parent will refocus the custom input
|
|
80
322
|
});
|
|
81
323
|
</script>
|
|
82
|
-
|
|
83
|
-
<template>
|
|
84
|
-
<div ref="timePickerRef">
|
|
85
|
-
<TimePicker :show="showPicker" @open="showPicker=true" @close="showPicker=false" />
|
|
86
|
-
</div>
|
|
87
|
-
</template>
|
|
88
324
|
```
|
|
89
325
|
|
|
90
326
|
## API
|
|
@@ -101,6 +337,7 @@ Sets up focus trap and close behaviors for a discovered popup element.
|
|
|
101
337
|
- `triggerEl?: Ref<HTMLElement | null>`: The trigger/root element used as a scope to discover the popup.
|
|
102
338
|
- `resolvePopupEl?: () => HTMLElement | null`: Provide your own resolver when direct selection is needed.
|
|
103
339
|
- `initialFocus?: InitialFocus`: The initial focus target inside the popup: a CSS selector string or function `(root) => HTMLElement | null`.
|
|
340
|
+
- `outsideClick?: false | PopupTrapOutsideClickOptions`: Omitted or `{}` preserves outside dismissal; `false` registers no outside listener; `{ ignore }` forwards VueUse's typed ignore configuration. Enablement is fixed at setup time; ignore entries remain reactive. The trigger is not automatically ignored. Escape, containment, and focus-restoration defaults are unchanged.
|
|
104
341
|
- `focusTrapOptions?: Parameters<typeof useFocusTrap>[1]`: Options forwarded to `useFocusTrap`.
|
|
105
342
|
- `returnFocusToTrigger?: boolean`: When closing, return focus to `triggerEl`. Defaults to `true`.
|
|
106
343
|
|
|
@@ -110,11 +347,28 @@ Sets up focus trap and close behaviors for a discovered popup element.
|
|
|
110
347
|
- `activate(): void`: Manually activate the focus trap.
|
|
111
348
|
- `deactivate(): void`: Manually deactivate the focus trap.
|
|
112
349
|
- `setPopupEl(el: HTMLElement | null): void`: Override the discovered popup element.
|
|
350
|
+
- `lastCloseReason: Readonly<Ref<CloseReason | null>>`: Last close-request reason, not necessarily an accepted closure. Resets to `null` on open and is preserved while closed. Disabled or ignored outside interactions do not update it; button-driven closure does not assign a new reason.
|
|
351
|
+
|
|
352
|
+
#### Presets
|
|
353
|
+
|
|
354
|
+
- `DIALOG_TRAP_OPTIONS = { outsideClick: false } as const`: Disables all outside-click dismissal, retaining Escape and existing focus-restoration defaults. Spread first to allow deliberate overrides.
|
|
355
|
+
- `POPUP_MENU_TRAP_OPTIONS`: Includes `outsideClick: false` and disables competing focus restoration when paired with `usePopupMenu`. The menu retains its own outside listener and trigger-ignore policy.
|
|
356
|
+
|
|
357
|
+
Previously, the menu preset disabled focus restoration only. Callers that
|
|
358
|
+
intentionally relied on the trap's outside-close callback must override with
|
|
359
|
+
`outsideClick: {}` after spreading the preset. Normal `usePopupMenu` pairings
|
|
360
|
+
continue to dismiss outside through the menu's listener.
|
|
361
|
+
|
|
362
|
+
The presets and `PopupTrapOutsideClickOptions` are exported from both
|
|
363
|
+
`@featherk/composables/trap` and `@featherk/composables`.
|
|
113
364
|
|
|
114
365
|
#### Types
|
|
115
366
|
|
|
116
367
|
```ts
|
|
368
|
+
import type { OnClickOutsideOptions } from "@vueuse/core";
|
|
369
|
+
|
|
117
370
|
export type CloseReason = "escape" | "outside";
|
|
371
|
+
export type PopupTrapOutsideClickOptions = Pick<OnClickOutsideOptions, "ignore">;
|
|
118
372
|
export type InitialFocus = string | ((root: HTMLElement) => HTMLElement | null | undefined);
|
|
119
373
|
```
|
|
120
374
|
|
|
@@ -126,14 +380,16 @@ export type InitialFocus = string | ((root: HTMLElement) => HTMLElement | null |
|
|
|
126
380
|
- `.k-timepicker-popup`
|
|
127
381
|
- `.k-menu-popup`
|
|
128
382
|
- **Focus trap**: Uses `useFocusTrap(popupRef)` with a fallback/initial focus inside the popup. `escapeDeactivates` and `clickOutsideDeactivates` are disabled; closing is managed explicitly. `allowOutsideClick` defaults to `true` - without it, `focus-trap`'s own default (`false`) `preventDefault()`s and swallows *every* outside pointerdown/click while the trap is active, not just ones that would close it, blocking interaction with anything else on the page (e.g. a second trigger in the same grid row) until the trap deactivates. Override via `focusTrapOptions` only if a specific popup genuinely needs that stricter modal-style behavior.
|
|
129
|
-
- **Escape to close**: Adds a `keydown` listener on
|
|
130
|
-
- **Outside click to close**:
|
|
131
|
-
- **
|
|
383
|
+
- **Escape to close**: Adds a capture-phase `keydown` listener on `document` when opened; pressing Escape calls `onRequestClose('escape', event)`. The composable does not stop propagation; consumers retain ownership of nested Escape policy.
|
|
384
|
+
- **Outside click to close**: Unless disabled, uses `onClickOutside(popupRef, ...)` with VueUse's capture default to request closure when open. There should be one outside-dismissal owner per popup. Focus-trap stacking pauses containment, not this independent listener.
|
|
385
|
+
- **Separate policies**: `allowOutsideClick` permits interactions and `clickOutsideDeactivates` controls containment; neither configures FeatherK's independent close requests. Do not set `allowOutsideClick: false` to prevent dialog dismissal: it can block teleported child interactions. Modal background isolation remains the dialog component's responsibility.
|
|
386
|
+
- **Open/close lifecycle**: When `isOpen` becomes true, discovers the popup, sets `popupRef`, and activates the focus trap. When false, deactivates, removes the Escape listener, clears `popupRef`, and restores focus according to the configured policy. The outside listener is registered once at setup, guarded while closed, and removed by VueUse on component-scope disposal.
|
|
132
387
|
|
|
133
388
|
## Integration Patterns
|
|
134
389
|
|
|
135
|
-
- **Kendo DatePicker
|
|
136
|
-
- **
|
|
390
|
+
- **Kendo DatePicker**: Use `useMaskedDateInput` with a wrapped custom `MaskedTextBox` in the `dateInput` slot; pass `triggerEl` pointing to the DatePicker's DOM wrapper and `isOpen` from its open state.
|
|
391
|
+
- **Date range calendar**: Use `useMaskedDateRangeInput` with its own `MaskedTextBox`, value, and popup state; trap the separate `Calendar`/`Popup` using its owned popup selector or resolver.
|
|
392
|
+
- **Kendo TimePicker**: Use `useMaskedTimeInput` with a wrapped custom `MaskedTextBox`, including its `rules`, and provide `popupSelector` like `[".k-timeselector", ".k-popup"]`. Wire the TimePicker's `@change` to `masked.handleChange` as well as the custom input's `@change` so Now/Set actions use the composable's value and focus handling.
|
|
137
393
|
- Note: Kendo Vue `TimePicker` uses the `dateInput` slot/prop to replace its internal input (same as `DatePicker`). Use `dateInput="masked"` and define the `#masked` slot; `timeInput` will not work.
|
|
138
394
|
- **Manual resolution**: When a popup is outside the default scope, set `resolvePopupEl` to return the element directly.
|
|
139
395
|
- **Initial focus**: Use `initialFocus` to direct focus to a primary interactive element inside the popup (e.g., first calendar cell or button).
|