@plastic-js/tsumiki 0.1.67 → 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 +2 -2
- package/dist/components/Select.js +27 -3
- package/docs/api.md +1 -1
- package/docs/select-in-dialog-focus-lock.md +37 -14
- package/package.json +2 -1
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**.
|
|
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
|
|
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>");
|
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
37
|
-
the filter input keeps focus after a
|
|
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.
|
|
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
|
},
|