@featherk/composables 0.15.3 → 0.15.5

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/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
- * Focus-restoration options for pairing `usePopupTrap` with `usePopupMenu`.
8
+ * Dismissal and focus-restoration options for pairing with `usePopupMenu`.
7
9
  *
8
- * `usePopupMenu` owns menu-close focus restoration. These settings disable
9
- * both competing restoration paths in `usePopupTrap` and the underlying
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. Omit `onRequestClose` entirely. `usePopupTrap` has its own outside-click/Escape detection, but without `onRequestClose` it never acts on it - `usePopupMenu` remains the sole decision-maker for when the popup closes. Passing `onRequestClose` here would let two composables independently decide to close on the same interaction.
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 so `usePopupMenu`
297
- remains the sole owner of focus restoration. `initialFocus` stays explicit
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, and disable
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
- // Shared model for TimePicker and composable
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
- // Integrate focus trap for the TimePicker popup
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
- ```ts
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
- When pairing this composable with `usePopupMenu`, the optional
40
- `POPUP_MENU_TRAP_OPTIONS` export disables the trap's competing focus
41
- restoration paths:
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
- isOpen,
51
- triggerEl,
52
- initialFocus: () => document.activeElement as HTMLElement | null,
53
- ...POPUP_MENU_TRAP_OPTIONS,
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
- Keep `initialFocus` explicit because the correct focus target is specific to
58
- the popup and its owning composable. `allowOutsideClick` is already `true` by
59
- default and is not repeated in this preset. Spread this preset last when using
60
- it with `usePopupMenu`; its focus-restoration settings are required for that
61
- integration and should take precedence over conflicting options.
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
- ### TimePicker example (custom selectors)
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, useTemplateRef } from "vue";
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 = useTemplateRef("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 the popup when opened; pressing Escape calls `onRequestClose('escape', event)` and stops propagation.
130
- - **Outside click to close**: Uses `onClickOutside(popupRef, ...)` to call `onRequestClose('outside', event)` when open.
131
- - **Open/close lifecycle**: When `isOpen` becomes true, discovers the popup, sets `popupRef`, and activates the focus trap. When it becomes false, deactivates, removes listeners, clears `popupRef`, and optionally returns focus to `triggerEl`.
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/Popup**: Use defaults; pass `triggerEl` pointing to the DatePicker wrapper and `isOpen` from the component’s open state.
136
- - **Kendo TimePicker**: Provide `popupSelector` like `[".k-timeselector", ".k-popup"]` to target the menu container reliably.
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featherk/composables",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "main": "dist/featherk-composables.umd.js",
5
5
  "module": "dist/featherk-composables.es.js",
6
6
  "types": "dist/index.d.ts",