@plastic-js/tsumiki 0.1.66 → 0.1.68

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/README.md CHANGED
@@ -81,7 +81,7 @@ function Example(){
81
81
  | `className` | `string` | — | CSS class for the trigger button |
82
82
  | `children` | `node` | — | Custom trigger content (replaces default label + chevron) |
83
83
 
84
- > **Note on Trigger element:** The trigger renders `<div role="button" tabIndex={0}>` instead of a native `<button>` as a defense-in-depth measure against a **Chrome iOS (WebKit) focus-lock bug**. The sheet is rendered inline (no `<Portal>`) so it stays inside the parent Dialog's focus-trap boundary.
84
+ > **Note on Trigger element:** The trigger renders `<div role="button" tabIndex={0}>` instead of a native `<button>` as a defense-in-depth measure against a **Chrome iOS (WebKit) focus-lock bug**. Inside a Dialog the sheet still portals to `<body>` as an independent overlay; the Dialog's focus trap is temporarily paused while the sheet is open (shared `@zag-js/focus-trap` stack) so the sheet's filter input keeps focus on iOS.
85
85
 
86
86
  **Filter usage:**
87
87
 
@@ -95,7 +95,7 @@ function Example(){
95
95
  <Select filter={(item, query, itemToLabel) => itemToLabel(item).toLowerCase().startsWith(query)} ...>
96
96
  ```
97
97
 
98
- The search input is auto-focused when the sheet opens and cleared when it closes. "No results" is shown when no items match the query.
98
+ The search input is intentionally **not** auto-focused on open (auto-focus mid-slide leaves the field half-focused on iOS); tap to focus. It is cleared when the sheet closes. "No results" is shown when no items match the query.
99
99
 
100
100
  ---
101
101
 
@@ -282,7 +282,7 @@ var DatePicker = (props = {}) => {
282
282
  disabled: false,
283
283
  intent: "default",
284
284
  invalid: false,
285
- closeOnBackdrop: true
285
+ closeOnBackdrop: false
286
286
  }, props), [
287
287
  "value",
288
288
  "onValueChange",
@@ -3,8 +3,10 @@ import Icon from "./Icon.js";
3
3
  import { Fragment, insert, jsx, mergeProps, setProp, template } from "@plastic-js/plastic/jsx-runtime";
4
4
  import { css } from "@emotion/css";
5
5
  import { useDialogContext } from "@plastic-js/ark";
6
- import { Loop, createContext, createEffect, createSignal, mergeProps as mergeProps$2, splitProps, useContext } from "@plastic-js/plastic";
6
+ import { Loop, createContext, createEffect, createSignal, mergeProps as mergeProps$2, onCleanup, onMount, splitProps, useContext } from "@plastic-js/plastic";
7
+ import { trapFocus } from "@zag-js/focus-trap";
7
8
  //#region src/components/Select.jsx
9
+ var _tmpl4 = template("<div></div>");
8
10
  var _tmpl3 = template("<div>No results</div>");
9
11
  var _tmpl2 = template("<button type=\"button\" aria-label=\"Clear search\"></button>");
10
12
  var _tmpl = template("<div><span aria-hidden=\"true\"></span><input type=\"text\" placeholder=\"Search…\"><!></div>");
@@ -417,8 +419,30 @@ var List = () => {
417
419
  }]
418
420
  }));
419
421
  };
422
+ var PauseDialogTrap = ({ children }) => {
423
+ let node = null;
424
+ let destroy = null;
425
+ onMount(() => {
426
+ if (!node) return;
427
+ destroy = trapFocus(() => node, {
428
+ initialFocus: false,
429
+ allowOutsideClick: true,
430
+ escapeDeactivates: false
431
+ });
432
+ });
433
+ onCleanup(() => destroy?.());
434
+ return () => {
435
+ const _el0 = _tmpl4.cloneNode(true);
436
+ insert(_el0, () => children);
437
+ setProp(_el0, "ref", (el) => {
438
+ node = el;
439
+ });
440
+ return _el0;
441
+ };
442
+ };
420
443
  var Content = () => {
421
444
  const ctx = useSelect();
445
+ const dialog = useDialogContext();
422
446
  return jsx(BottomSheet, mergeProps({
423
447
  get open() {
424
448
  return ctx.open;
@@ -432,8 +456,8 @@ var Content = () => {
432
456
  return ctx.backdropStyle ?? { background: "var(--tsu-bg-overlay)" };
433
457
  },
434
458
  contentClassName: listBodyClass,
435
- portal: !useDialogContext(),
436
- children: jsx(List, {})
459
+ portal: true,
460
+ children: () => dialog ? jsx(PauseDialogTrap, mergeProps({ children: jsx(List, {}) })) : jsx(List, {})
437
461
  }));
438
462
  };
439
463
  //#endregion
package/docs/api.md CHANGED
@@ -729,7 +729,7 @@ A free-form presentation layer: a backdrop, an optional drag grabber, and a scro
729
729
  | `backdropClassName` | `string` | — | Extra class for the backdrop. |
730
730
  | `backdropStyle` | `object` | — | Extra style for the backdrop. |
731
731
  | `contentClassName` | `string` | — | Extra class for the content area. |
732
- | `portal` | `boolean` | `true` | Render the overlay into `<body>`. When `false` the backdrop + sheet render **inline**, keeping them inside a parent Dialog's focus-trap boundary required for a filter `input` nested in a Dialog on iOS Safari (WebKit focus-lock). The overlay is `position: fixed`, so it still covers the viewport when inline. |
732
+ | `portal` | `boolean` | `true` | Render the overlay into `<body>`. When `false` the backdrop + sheet render **inline** at the call site. Prefer keeping `true`: inside a Dialog the overlay needs its own full-screen backdrop, and iOS pins `position: fixed` descendants to a touch-scroll container, so an inline sheet inside a Dialog body renders as plain dialog content. `Select` always portals and pauses the parent Dialog's focus trap while its sheet is open. |
733
733
  | `class` | `string` | — | Extra class for the sheet. |
734
734
  | `children` | `node` | — | **Required** body content. |
735
735
 
@@ -1,9 +1,8 @@
1
- # Select-in-Dialog filter input focus-lock on iOS
1
+ # Select-in-Dialog on iOS
2
2
 
3
- **Status:** Fixed
4
3
  **Affects:** `Select.jsx` (nested inside `Dialog`)
5
4
 
6
- ## Problem
5
+ ## Problem (original)
7
6
 
8
7
  A `Select` with a filter input, nested inside a `Dialog`, could not be focused on
9
8
  iOS Safari — tapping the search box did nothing and the keyboard never appeared.
@@ -20,19 +19,43 @@ fixed it by rendering the sheet **inline** (no `<Portal>`), keeping it inside
20
19
  the dialog's focus-trap boundary. The current `Select` re-introduced the bug by
21
20
  routing through `BottomSheet`, which always portaled.
22
21
 
23
- ## Fix
22
+ ## Problem (regression from the inline fix)
23
+
24
+ Rendering the sheet inline inside the Dialog fixed focus, but created a
25
+ different iOS-only bug: the Dialog body is a touch-scroll container
26
+ (`overflow-y: auto` + `-webkit-overflow-scrolling: touch`). iOS WebKit pins any
27
+ `position: fixed` descendant to that container, so the sheet's backdrop
28
+ (`position: fixed; inset: 0`) only dimmed the dialog area and the sheet stuck to
29
+ the dialog content — it looked like plain dialog content with **no independent
30
+ backdrop**. Desktop Chrome (Blink) applies `-webkit-overflow-scrolling: touch`
31
+ differently, so the same markup rendered as a proper full-screen overlay there,
32
+ which is why the bug was only visible on real iOS devices.
24
33
 
25
- - `BottomSheet` gains a `portal` prop (default `true`, backward compatible).
26
- When `false`, the backdrop + sheet render inline instead of into `<body>`.
27
- - `Select.Content` reads `useDialogContext()` and passes `portal={!dialog}`:
28
- inside a Dialog the sheet stays within the focus-trap boundary; standalone
29
- usage still portals as before.
34
+ ## Fix
30
35
 
31
- Because `BottomSheet`'s overlay is `position: fixed`, rendering inline still
32
- covers the viewport correctly even when nested inside the dialog.
36
+ The sheet is portaled to `<body>` **always**, so it renders as an independent
37
+ overlay with its own full-screen backdrop, and the iOS focus-lock is solved
38
+ properly instead of worked around:
39
+
40
+ - `BottomSheet` keeps its `portal` prop (default `true`).
41
+ - `Select.Content` always passes `portal`.
42
+ - When a Dialog is present, `Select.Content` wraps the list in
43
+ `PauseDialogTrap`, which calls `trapFocus()` (from `@zag-js/focus-trap`) on the
44
+ sheet content for the sheet's lifetime. `@zag-js/focus-trap` keeps a **shared
45
+ trap stack**, so activating this trap pauses the parent Dialog's focus trap
46
+ and deactivating it resumes the Dialog's. The sheet's filter input therefore
47
+ keeps focus on iOS even though it lives outside the dialog's DOM.
48
+ - `trapFocus` is configured with `initialFocus: false` (never steal focus on
49
+ open — the input is deliberately not auto-focused), `allowOutsideClick: true`
50
+ (backdrop dismiss + grabber drag still work), and `escapeDeactivates: false`
51
+ (the Select closes on Escape itself).
52
+
53
+ Both fixes must share the same `@zag-js/focus-trap` module instance for the
54
+ stack to be shared, so tsumiki pins the exact version (`1.40.0`) that
55
+ `@zag-js/dialog` (via Ark) uses.
33
56
 
34
57
  ## e2e
35
58
 
36
- `e2e/select-in-dialog-*` verifies the sheet stays inside the dialog content,
37
- the filter input keeps focus after a tap, and closing the sheet via backdrop
38
- leaves the dialog open.
59
+ `e2e/select-in-dialog-*` verifies the sheet portals to `<body>` (independent
60
+ overlay, outside the dialog content tree), the filter input keeps focus after a
61
+ tap, and closing the sheet via backdrop leaves the dialog open.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/tsumiki",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "description": "A UI component library for Plastic JS.",
5
5
  "license": "MIT",
6
6
  "author": "tigre",
@@ -57,6 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@emotion/css": "^11.13.5",
60
+ "@zag-js/focus-trap": "1.40.0",
60
61
  "@zag-js/select": "1.40.0",
61
62
  "popmotion": "^11.0.5"
62
63
  },