@plastic-js/tsumiki 0.1.67 → 0.1.69

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
 
@@ -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>");
@@ -103,6 +105,12 @@ var triggerIndicatorClass = css({
103
105
  }
104
106
  });
105
107
  var listBodyClass = css({ padding: 0 });
108
+ var pauseTrapWrapClass = css({
109
+ display: "flex",
110
+ flexDirection: "column",
111
+ flex: 1,
112
+ minHeight: 0
113
+ });
106
114
  var listClass = css({
107
115
  flex: 1,
108
116
  minHeight: 0,
@@ -417,8 +425,31 @@ var List = () => {
417
425
  }]
418
426
  }));
419
427
  };
428
+ var PauseDialogTrap = ({ children }) => {
429
+ let node = null;
430
+ let destroy = null;
431
+ onMount(() => {
432
+ if (!node) return;
433
+ destroy = trapFocus(() => node, {
434
+ initialFocus: false,
435
+ allowOutsideClick: true,
436
+ escapeDeactivates: false
437
+ });
438
+ });
439
+ onCleanup(() => destroy?.());
440
+ return () => {
441
+ const _el0 = _tmpl4.cloneNode(true);
442
+ insert(_el0, () => children);
443
+ setProp(_el0, "className", () => pauseTrapWrapClass);
444
+ setProp(_el0, "ref", (el) => {
445
+ node = el;
446
+ });
447
+ return _el0;
448
+ };
449
+ };
420
450
  var Content = () => {
421
451
  const ctx = useSelect();
452
+ const dialog = useDialogContext();
422
453
  return jsx(BottomSheet, mergeProps({
423
454
  get open() {
424
455
  return ctx.open;
@@ -432,8 +463,8 @@ var Content = () => {
432
463
  return ctx.backdropStyle ?? { background: "var(--tsu-bg-overlay)" };
433
464
  },
434
465
  contentClassName: listBodyClass,
435
- portal: !useDialogContext(),
436
- children: jsx(List, {})
466
+ portal: true,
467
+ children: () => dialog ? jsx(PauseDialogTrap, mergeProps({ children: jsx(List, {}) })) : jsx(List, {})
437
468
  }));
438
469
  };
439
470
  //#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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/tsumiki",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
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
  },
@@ -1,38 +0,0 @@
1
- # Select-in-Dialog filter input focus-lock on iOS
2
-
3
- **Status:** Fixed
4
- **Affects:** `Select.jsx` (nested inside `Dialog`)
5
-
6
- ## Problem
7
-
8
- A `Select` with a filter input, nested inside a `Dialog`, could not be focused on
9
- iOS Safari — tapping the search box did nothing and the keyboard never appeared.
10
-
11
- The `Dialog` (ArkDialog `modal`) installs a focus trap that listens on
12
- `document` and forces focus back into the dialog content whenever it escapes.
13
- The Select's bottom sheet was portaled to `document.body`, which is **outside**
14
- the dialog's focus-trap boundary. On iOS Safari the trap's `focusin` handler
15
- stole focus back from the filter input on every tap, so the input never kept
16
- focus long enough for the keyboard to appear.
17
-
18
- The old `SelectMobile` component hit the exact same WebKit focus-lock bug and
19
- fixed it by rendering the sheet **inline** (no `<Portal>`), keeping it inside
20
- the dialog's focus-trap boundary. The current `Select` re-introduced the bug by
21
- routing through `BottomSheet`, which always portaled.
22
-
23
- ## Fix
24
-
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.
30
-
31
- Because `BottomSheet`'s overlay is `position: fixed`, rendering inline still
32
- covers the viewport correctly even when nested inside the dialog.
33
-
34
- ## e2e
35
-
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.