@plastic-js/tsumiki 0.1.75 → 0.1.77

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**. 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.
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; when the enclosing Dialog is in `modal` mode its 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. (A sheet-mode Dialog is non-modal and has no focus trap to pause.)
85
85
 
86
86
  **Filter usage:**
87
87
 
@@ -1,6 +1,8 @@
1
1
  import Portal_default from "./Portal.js";
2
2
  import Button from "./Button.js";
3
3
  import Icon from "./Icon.js";
4
+ import { bodyClass, closeBtnClass, footerBtnClass, footerClass, headerClass, negativeBtnClass, positiveBtnClass, titleClass, titleIconClass, titleWrapClass } from "./dialogParts.js";
5
+ import DialogSheet from "./DialogSheet.js";
4
6
  import { insert, jsx, mergeProps, setProp, template } from "@plastic-js/plastic/jsx-runtime";
5
7
  import { css, keyframes } from "@emotion/css";
6
8
  import { Dialog, useDialogContext } from "@plastic-js/ark";
@@ -17,13 +19,6 @@ var backdropModalClass = css({
17
19
  backdropFilter: "blur(8px)",
18
20
  WebkitBackdropFilter: "blur(8px)"
19
21
  });
20
- var backdropSheetClass = css({
21
- position: "fixed",
22
- inset: 0,
23
- background: "var(--tsu-bg-overlay-soft)",
24
- backdropFilter: "blur(8px)",
25
- WebkitBackdropFilter: "blur(8px)"
26
- });
27
22
  var positionerModalClass = css({
28
23
  position: "fixed",
29
24
  inset: 0,
@@ -32,28 +27,6 @@ var positionerModalClass = css({
32
27
  justifyContent: "center",
33
28
  padding: "var(--tsu-spacing-lg)"
34
29
  });
35
- var positionerSheetClass = css({
36
- position: "fixed",
37
- inset: 0,
38
- display: "flex",
39
- alignItems: "flex-end",
40
- justifyContent: "center",
41
- padding: 0
42
- });
43
- var modalContentIn = keyframes({
44
- from: {
45
- opacity: 0,
46
- transform: "scale(0.95)"
47
- },
48
- to: {
49
- opacity: 1,
50
- transform: "scale(1)"
51
- }
52
- });
53
- var sheetContentIn = keyframes({
54
- from: { transform: "translateY(100%)" },
55
- to: { transform: "translateY(0)" }
56
- });
57
30
  var contentModalClass = css({
58
31
  background: "var(--tsu-bg-panel)",
59
32
  borderRadius: "var(--tsu-radius-l3-md)",
@@ -65,95 +38,18 @@ var contentModalClass = css({
65
38
  flexDirection: "column",
66
39
  color: "var(--tsu-fg)",
67
40
  boxShadow: "var(--tsu-shadow-lg)",
68
- animation: `${modalContentIn} 200ms ease`
69
- });
70
- var contentSheetClass = css({
71
- background: "var(--tsu-bg-panel)",
72
- borderRadius: "var(--tsu-radius-l3-md) var(--tsu-radius-l3-md) 0 0",
73
- width: "100%",
74
- maxHeight: "70vh",
75
- display: "flex",
76
- flexDirection: "column",
77
- color: "var(--tsu-fg)",
78
- boxShadow: "var(--tsu-shadow-lg)",
79
- animation: `${sheetContentIn} 250ms ease`
80
- });
81
- var headerClass = css({
82
- display: "flex",
83
- alignItems: "center",
84
- justifyContent: "space-between",
85
- padding: "18px var(--tsu-container-padding-lg) var(--tsu-spacing-lg)",
86
- marginBottom: "var(--tsu-spacing-sm)",
87
- borderBottom: "1px solid var(--tsu-border)",
88
- flexShrink: 0
89
- });
90
- var titleWrapClass = css({
91
- display: "flex",
92
- alignItems: "center",
93
- gap: "10px",
94
- minWidth: 0
95
- });
96
- var titleIconClass = css({
97
- flexShrink: 0,
98
- color: "var(--tsu-accent-solid-bg)"
99
- });
100
- var titleClass = css({
101
- fontSize: "18px",
102
- fontWeight: "var(--tsu-font-weight-semibold)",
103
- margin: 0
104
- });
105
- var closeBtnClass = css({
106
- width: "32px",
107
- height: "32px",
108
- borderRadius: "50%",
109
- background: "transparent",
110
- border: "none",
111
- color: "var(--tsu-text-secondary)",
112
- fontSize: "22px",
113
- lineHeight: 1,
114
- cursor: "pointer",
115
- display: "flex",
116
- alignItems: "center",
117
- justifyContent: "center",
118
- flexShrink: 0
119
- });
120
- var bodyClass = css({
121
- padding: "0 var(--tsu-container-padding-lg)",
122
- flex: 1,
123
- minHeight: 0,
124
- overflowY: "auto",
125
- overscrollBehavior: "contain"
126
- });
127
- var footerClass = css({
128
- display: "flex",
129
- gap: "var(--tsu-spacing-md)",
130
- padding: "var(--tsu-container-padding-lg)",
131
- flexShrink: 0
132
- });
133
- var footerSheetClass = css({ paddingBottom: "var(--tsu-container-padding-lg)" });
134
- var footerBtnClass = css({
135
- display: "flex",
136
- gap: "20px",
137
- width: "100%",
138
- justifyContent: "flex-end",
139
- "&.tsu-dialog-footer--center": { justifyContent: "center" }
140
- });
141
- var negativeBtnClass = css({
142
- flex: 1,
143
- borderRadius: "var(--tsu-radius-l1-lg)",
144
- fontSize: "var(--tsu-comp-font-size-lg)",
145
- fontWeight: "var(--tsu-font-weight-semibold)",
146
- fontFamily: "inherit"
147
- });
148
- var positiveBtnClass = css({
149
- flex: 1,
150
- borderRadius: "var(--tsu-radius-l1-lg)",
151
- fontSize: "var(--tsu-comp-font-size-lg)",
152
- fontWeight: "var(--tsu-font-weight-semibold)",
153
- fontFamily: "inherit"
41
+ animation: `${keyframes({
42
+ from: {
43
+ opacity: 0,
44
+ transform: "scale(0.95)"
45
+ },
46
+ to: {
47
+ opacity: 1,
48
+ transform: "scale(1)"
49
+ }
50
+ })} 200ms ease`
154
51
  });
155
52
  var read = (v) => typeof v === "function" ? v() : v;
156
- var warnedSheetNoTitle = false;
157
53
  var DialogPortal = (props) => {
158
54
  const dialog = useDialogContext();
159
55
  return jsx(Either, mergeProps({
@@ -162,6 +58,7 @@ var DialogPortal = (props) => {
162
58
  falseBranch: () => null
163
59
  }));
164
60
  };
61
+ var warnedSheetNoTitle = false;
165
62
  var Dialog$1 = (props) => {
166
63
  const [local, rest] = splitProps(mergeProps$2({
167
64
  mode: "sheet",
@@ -200,6 +97,7 @@ var Dialog$1 = (props) => {
200
97
  "children"
201
98
  ]);
202
99
  const cancelRequested = { current: false };
100
+ const closeInFlight = { current: false };
203
101
  const handleOpenChange = (open) => {
204
102
  if (!open) {
205
103
  const wasCancelled = cancelRequested.current;
@@ -214,11 +112,28 @@ var Dialog$1 = (props) => {
214
112
  };
215
113
  const handleCancel = () => {
216
114
  cancelRequested.current = true;
115
+ closeInFlight.current = true;
217
116
  handleOpenChange(false);
218
117
  };
219
118
  const handleConfirm = () => {
220
119
  local.onConfirm?.();
221
120
  };
121
+ const notifyOpen = () => {
122
+ closeInFlight.current = false;
123
+ handleOpenChange(true);
124
+ };
125
+ const notifyClose = () => {
126
+ if (closeInFlight.current) {
127
+ closeInFlight.current = false;
128
+ return;
129
+ }
130
+ handleOpenChange(false);
131
+ };
132
+ const dismiss = () => {
133
+ cancelRequested.current = true;
134
+ closeInFlight.current = true;
135
+ handleOpenChange(false);
136
+ };
222
137
  const isSheet = local.mode === "sheet";
223
138
  const onlyOneBtn = (local.showCancel ? 1 : 0) + (local.showConfirm ? 1 : 0) === 1;
224
139
  const footerContent = local.footer ? local.footer : local.showCancel || local.showConfirm ? () => {
@@ -259,6 +174,36 @@ var Dialog$1 = (props) => {
259
174
  warnedSheetNoTitle = true;
260
175
  console.warn("[Dialog] sheet mode requires a title; use BottomSheet for header-less content.");
261
176
  }
177
+ if (isSheet) return jsx(DialogSheet, mergeProps({
178
+ get className() {
179
+ return local.class;
180
+ },
181
+ get closeOnInteractOutside() {
182
+ return local.closeOnInteractOutside;
183
+ },
184
+ get children() {
185
+ return local.children;
186
+ },
187
+ footer: footerContent,
188
+ get icon() {
189
+ return local.icon;
190
+ },
191
+ get lazyMount() {
192
+ return local.lazyMount;
193
+ },
194
+ onCloseNotify: notifyClose,
195
+ onDismiss: dismiss,
196
+ onOpenEdge: notifyOpen,
197
+ get open() {
198
+ return local.open;
199
+ },
200
+ get title() {
201
+ return local.title;
202
+ },
203
+ get unmountOnExit() {
204
+ return local.unmountOnExit;
205
+ }
206
+ }));
262
207
  return jsx(Dialog.Root, mergeProps({
263
208
  get open() {
264
209
  return read(local.open);
@@ -280,11 +225,11 @@ var Dialog$1 = (props) => {
280
225
  return local.closeOnInteractOutside;
281
226
  },
282
227
  modal: true
283
- }, rest, { children: jsx(DialogPortal, mergeProps({ children: [jsx(Dialog.Backdrop, mergeProps({ className: isSheet ? backdropSheetClass : backdropModalClass })), jsx(Dialog.Positioner, mergeProps({
284
- className: isSheet ? positionerSheetClass : positionerModalClass,
228
+ }, rest, { children: jsx(DialogPortal, mergeProps({ children: [jsx(Dialog.Backdrop, mergeProps({ className: backdropModalClass })), jsx(Dialog.Positioner, mergeProps({
229
+ className: positionerModalClass,
285
230
  children: jsx(Dialog.Content, mergeProps({
286
231
  get className() {
287
- return local.class ? `${isSheet ? contentSheetClass : contentModalClass} ${local.class}` : isSheet ? contentSheetClass : contentModalClass;
232
+ return local.class ? `${contentModalClass} ${local.class}` : contentModalClass;
288
233
  },
289
234
  children: [
290
235
  () => {
@@ -305,7 +250,7 @@ var Dialog$1 = (props) => {
305
250
  children: () => local.title
306
251
  })), _el3);
307
252
  setProp(_el1, "className", () => titleWrapClass);
308
- insert(_el0, () => !isSheet && (local.closeable ?? true) && jsx(Dialog.CloseTrigger, mergeProps({
253
+ insert(_el0, () => (local.closeable ?? true) && jsx(Dialog.CloseTrigger, mergeProps({
309
254
  "aria-label": "Close",
310
255
  className: closeBtnClass,
311
256
  onClick: () => {
@@ -325,7 +270,7 @@ var Dialog$1 = (props) => {
325
270
  () => {
326
271
  const _el0 = _tmpl4.cloneNode(true);
327
272
  insert(_el0, () => footerContent);
328
- setProp(_el0, "className", () => isSheet ? `${footerClass} ${footerSheetClass}` : footerClass);
273
+ setProp(_el0, "className", () => footerClass);
329
274
  return _el0;
330
275
  }
331
276
  ]
@@ -0,0 +1,209 @@
1
+ import Portal_default from "./Portal.js";
2
+ import Icon from "./Icon.js";
3
+ import { bodyClass, footerClass, headerClass, titleClass, titleIconClass, titleWrapClass } from "./dialogParts.js";
4
+ import { Fragment, insert, jsx, mergeProps, setProp, template } from "@plastic-js/plastic/jsx-runtime";
5
+ import { css, keyframes } from "@emotion/css";
6
+ import { Either, createEffect, createSignal, onCleanup } from "@plastic-js/plastic";
7
+ //#region src/components/DialogSheet.jsx
8
+ var _tmpl4 = template("<div></div>");
9
+ var _tmpl3 = template("<div data-scope=\"dialog\" data-part=\"header\"><div><!><!></div></div>");
10
+ var _tmpl2 = template("<div role=\"dialog\" aria-modal=\"true\" data-scope=\"dialog\" data-part=\"sheet\"><!><div data-scope=\"dialog\" data-part=\"body\"></div><div data-scope=\"dialog\" data-part=\"footer\"></div><div aria-hidden=\"true\"></div></div>");
11
+ var _tmpl = template("<button type=\"button\" aria-label=\"Dismiss\"></button>");
12
+ var sheetBackdropClass = css({
13
+ position: "fixed",
14
+ inset: 0,
15
+ zIndex: 1400,
16
+ padding: 0,
17
+ border: "none",
18
+ background: "var(--tsu-bg-overlay-soft)",
19
+ backdropFilter: "blur(8px)",
20
+ WebkitBackdropFilter: "blur(8px)",
21
+ opacity: 0,
22
+ pointerEvents: "none",
23
+ cursor: "default",
24
+ transition: "opacity var(--tsu-transition-overlay-exit)",
25
+ "&[data-open]": {
26
+ opacity: 1,
27
+ pointerEvents: "auto",
28
+ transition: "opacity var(--tsu-transition-overlay-enter)"
29
+ }
30
+ });
31
+ var sheetPanelClass = css({
32
+ position: "fixed",
33
+ insetInline: 0,
34
+ bottom: 0,
35
+ zIndex: 1400,
36
+ background: "var(--tsu-bg-panel)",
37
+ borderRadius: "var(--tsu-radius-l3-md) var(--tsu-radius-l3-md) 0 0",
38
+ width: "100%",
39
+ maxHeight: "70vh",
40
+ display: "flex",
41
+ flexDirection: "column",
42
+ color: "var(--tsu-fg)",
43
+ boxShadow: "var(--tsu-shadow-lg)",
44
+ transform: "translateY(100%)",
45
+ pointerEvents: "none",
46
+ transition: "transform var(--tsu-transition-overlay-exit)",
47
+ "&[data-open]": {
48
+ transform: "translateY(0)",
49
+ pointerEvents: "auto",
50
+ transition: "transform var(--tsu-transition-overlay-enter)",
51
+ animation: `${keyframes({
52
+ from: { transform: "translateY(100%)" },
53
+ to: { transform: "translateY(0)" }
54
+ })} var(--tsu-duration-overlay-enter) var(--tsu-ease-out)`
55
+ }
56
+ });
57
+ var sheetBottomPadClass = css({
58
+ flexShrink: 0,
59
+ paddingBottom: "env(safe-area-inset-bottom, 0px)"
60
+ });
61
+ var read = (v) => typeof v === "function" ? v() : v;
62
+ var DEFAULT_EXIT_MS = 200;
63
+ var toMs = (s) => {
64
+ const v = String(s).trim().toLowerCase();
65
+ if (!v) return 0;
66
+ if (v.endsWith("ms")) return parseFloat(v) || 0;
67
+ if (v.endsWith("s")) return (parseFloat(v) || 0) * 1e3;
68
+ return parseFloat(v) || 0;
69
+ };
70
+ var titleSeq = 0;
71
+ var DialogSheet = (props) => {
72
+ const isOpen = () => read(props.open);
73
+ const render = createSignal(!read(props.lazyMount) || isOpen());
74
+ let exitTimer = null;
75
+ let prevFocusEl = null;
76
+ let inerted = [];
77
+ let prevOpen = isOpen();
78
+ let sheetEl = null;
79
+ const measureExitDuration = () => {
80
+ if (!sheetEl) return DEFAULT_EXIT_MS;
81
+ const duration = getComputedStyle(sheetEl).transitionDuration;
82
+ const ms = String(duration).split(",").reduce((max, d) => Math.max(max, toMs(d)), 0);
83
+ return ms > 0 ? ms : DEFAULT_EXIT_MS;
84
+ };
85
+ const applyInert = () => {
86
+ if (inerted.length > 0) return;
87
+ const host = sheetEl?.parentElement;
88
+ inerted = Array.from(document.body.children).filter((el) => el !== host && el !== sheetEl).map((el) => {
89
+ const was = el.inert;
90
+ el.inert = true;
91
+ return {
92
+ el,
93
+ was
94
+ };
95
+ });
96
+ };
97
+ const clearInert = () => {
98
+ inerted.forEach(({ el, was }) => {
99
+ el.inert = was;
100
+ });
101
+ inerted = [];
102
+ };
103
+ const restoreFocus = (duration) => {
104
+ const el = prevFocusEl;
105
+ if (!el || el === document.body || !el.isConnected) return;
106
+ setTimeout(() => {
107
+ if (el.isConnected) el.focus?.({ preventScroll: true });
108
+ }, duration + 50);
109
+ };
110
+ onCleanup(() => {
111
+ if (exitTimer) clearTimeout(exitTimer);
112
+ clearInert();
113
+ });
114
+ createEffect(() => {
115
+ const cur = isOpen();
116
+ if (cur && !prevOpen) {
117
+ prevFocusEl = document.activeElement;
118
+ props.onOpenEdge?.();
119
+ }
120
+ if (cur) {
121
+ if (exitTimer) {
122
+ clearTimeout(exitTimer);
123
+ exitTimer = null;
124
+ }
125
+ render(true);
126
+ applyInert();
127
+ } else if (prevOpen) {
128
+ clearInert();
129
+ props.onCloseNotify?.();
130
+ const duration = measureExitDuration();
131
+ restoreFocus(duration);
132
+ if (read(props.unmountOnExit) && render()) exitTimer = setTimeout(() => render(false), duration);
133
+ }
134
+ prevOpen = cur;
135
+ });
136
+ createEffect(() => {
137
+ if (!isOpen()) return;
138
+ const onKey = (e) => {
139
+ if (e.key === "Escape" || e.key === "Esc") {
140
+ e.preventDefault();
141
+ props.onDismiss?.();
142
+ }
143
+ };
144
+ document.addEventListener("keydown", onKey);
145
+ onCleanup(() => document.removeEventListener("keydown", onKey));
146
+ });
147
+ const handleBackdropClick = () => {
148
+ if (read(props.closeOnInteractOutside)) props.onDismiss?.();
149
+ };
150
+ const title = read(props.title);
151
+ const titleId = title ? `tsu-dialog-sheet-title-${++titleSeq}` : void 0;
152
+ const renderOverlay = () => jsx(Fragment, { children: [(() => {
153
+ const _el0 = _tmpl.cloneNode(true);
154
+ setProp(_el0, "tabIndex", () => -1);
155
+ setProp(_el0, "className", () => sheetBackdropClass);
156
+ setProp(_el0, "data-open", () => isOpen() ? "" : void 0);
157
+ setProp(_el0, "onClick", () => handleBackdropClick);
158
+ return _el0;
159
+ })(), () => {
160
+ const _el0 = _tmpl2.cloneNode(true);
161
+ const _el1 = _el0.firstChild;
162
+ const _el2 = _el0.firstChild.nextSibling;
163
+ const _el3 = _el0.firstChild.nextSibling.nextSibling;
164
+ const _el4 = _el0.firstChild.nextSibling.nextSibling.nextSibling;
165
+ insert(_el0, () => (title || props.icon) && (() => {
166
+ const _el0 = _tmpl3.cloneNode(true);
167
+ const _el1 = _el0.firstChild;
168
+ const _el2 = _el1.firstChild;
169
+ const _el3 = _el1.firstChild.nextSibling;
170
+ insert(_el1, () => props.icon && jsx(Icon, mergeProps({
171
+ className: titleIconClass,
172
+ size: 20,
173
+ get svg() {
174
+ return props.icon;
175
+ }
176
+ })), _el2);
177
+ insert(_el1, () => title && (() => {
178
+ const _el0 = _tmpl4.cloneNode(true);
179
+ insert(_el0, () => title);
180
+ setProp(_el0, "id", () => titleId);
181
+ setProp(_el0, "className", () => titleClass);
182
+ return _el0;
183
+ }), _el3);
184
+ setProp(_el1, "className", () => titleWrapClass);
185
+ setProp(_el0, "className", () => headerClass);
186
+ return _el0;
187
+ }), _el1);
188
+ insert(_el2, () => props.children);
189
+ setProp(_el2, "className", () => bodyClass);
190
+ insert(_el3, () => props.footer);
191
+ setProp(_el3, "className", () => footerClass);
192
+ setProp(_el4, "className", () => sheetBottomPadClass);
193
+ setProp(_el0, "ref", (el) => {
194
+ sheetEl = el;
195
+ });
196
+ setProp(_el0, "className", () => [sheetPanelClass, props.className].filter(Boolean).join(" "));
197
+ setProp(_el0, "data-open", () => isOpen() ? "" : void 0);
198
+ setProp(_el0, "aria-labelledby", () => titleId);
199
+ setProp(_el0, "aria-label", () => titleId ? void 0 : "Dialog");
200
+ return _el0;
201
+ }] });
202
+ return jsx(Either, mergeProps({
203
+ condition: () => render(),
204
+ trueBranch: () => jsx(Portal_default, mergeProps({ children: () => renderOverlay() })),
205
+ falseBranch: () => null
206
+ }));
207
+ };
208
+ //#endregion
209
+ export { DialogSheet as default };
@@ -0,0 +1,77 @@
1
+ import { css } from "@emotion/css";
2
+ //#region src/components/dialogParts.js
3
+ var headerClass = css({
4
+ display: "flex",
5
+ alignItems: "center",
6
+ justifyContent: "space-between",
7
+ padding: "18px var(--tsu-container-padding-lg) var(--tsu-spacing-lg)",
8
+ marginBottom: "var(--tsu-spacing-sm)",
9
+ borderBottom: "1px solid var(--tsu-border)",
10
+ flexShrink: 0
11
+ });
12
+ var titleWrapClass = css({
13
+ display: "flex",
14
+ alignItems: "center",
15
+ gap: "10px",
16
+ minWidth: 0
17
+ });
18
+ var titleIconClass = css({
19
+ flexShrink: 0,
20
+ color: "var(--tsu-accent-solid-bg)"
21
+ });
22
+ var titleClass = css({
23
+ fontSize: "18px",
24
+ fontWeight: "var(--tsu-font-weight-semibold)",
25
+ margin: 0
26
+ });
27
+ var closeBtnClass = css({
28
+ width: "32px",
29
+ height: "32px",
30
+ borderRadius: "50%",
31
+ background: "transparent",
32
+ border: "none",
33
+ color: "var(--tsu-text-secondary)",
34
+ fontSize: "22px",
35
+ lineHeight: 1,
36
+ cursor: "pointer",
37
+ display: "flex",
38
+ alignItems: "center",
39
+ justifyContent: "center",
40
+ flexShrink: 0
41
+ });
42
+ var bodyClass = css({
43
+ padding: "0 var(--tsu-container-padding-lg)",
44
+ flex: 1,
45
+ minHeight: 0,
46
+ overflowY: "auto",
47
+ overscrollBehavior: "contain"
48
+ });
49
+ var footerClass = css({
50
+ display: "flex",
51
+ gap: "var(--tsu-spacing-md)",
52
+ padding: "var(--tsu-container-padding-lg)",
53
+ flexShrink: 0
54
+ });
55
+ var footerBtnClass = css({
56
+ display: "flex",
57
+ gap: "20px",
58
+ width: "100%",
59
+ justifyContent: "flex-end",
60
+ "&.tsu-dialog-footer--center": { justifyContent: "center" }
61
+ });
62
+ var negativeBtnClass = css({
63
+ flex: 1,
64
+ borderRadius: "var(--tsu-radius-l1-lg)",
65
+ fontSize: "var(--tsu-comp-font-size-lg)",
66
+ fontWeight: "var(--tsu-font-weight-semibold)",
67
+ fontFamily: "inherit"
68
+ });
69
+ var positiveBtnClass = css({
70
+ flex: 1,
71
+ borderRadius: "var(--tsu-radius-l1-lg)",
72
+ fontSize: "var(--tsu-comp-font-size-lg)",
73
+ fontWeight: "var(--tsu-font-weight-semibold)",
74
+ fontFamily: "inherit"
75
+ });
76
+ //#endregion
77
+ export { bodyClass, closeBtnClass, footerBtnClass, footerClass, headerClass, negativeBtnClass, positiveBtnClass, titleClass, titleIconClass, titleWrapClass };
package/docs/api.md CHANGED
@@ -587,11 +587,16 @@ Passthrough: Ark `Root` props (`value`, `min`, `max`, …).
587
587
 
588
588
  **Exports:** `Dialog` · Escape hatch: `Dialog.origin.{Root, Trigger, Backdrop, Positioner, Content, Title, Description, CloseTrigger}`
589
589
 
590
- **Rendered DOM parts:** content is `[data-scope="dialog"][data-part="content"]`; tsumiki adds `data-part="header"`, `data-part="body"`, `data-part="footer"`, and the action buttons `data-part="negative-trigger"` / `data-part="positive-trigger"`. `title`, `description`, and `closeTrigger` carry `data-part` from Ark.
590
+ **Rendered DOM parts:** the two modes render differently:
591
+
592
+ - `modal` mode sits on the Ark dialog: content is `[data-scope="dialog"][data-part="content"]`; tsumiki adds `data-part="header"`, `data-part="body"`, `data-part="footer"`, and the action buttons `data-part="negative-trigger"` / `data-part="positive-trigger"`. `title`, `description`, and `closeTrigger` carry `data-part` from Ark.
593
+ - `sheet` mode is a standalone, BottomSheet-style overlay (not the Ark dialog). The dialog element itself carries `data-part="sheet"` (plus `data-part="header"` / `body` / `footer`); its title is a plain element wired through `aria-labelledby`. There is no Ark `closeTrigger` in sheet mode (no ✕ — see `closeable`).
591
594
 
592
595
  > **Note:** Dialog never renders a grabber / drag handle in either mode (`modal` or `sheet`). Drag-to-dismiss and the grabber are exclusive to [`BottomSheet`](#bottomsheet) — this is a deliberate difference between the two components.
593
596
 
594
- > **Note:** Dialog has **enter-only** animations — its content unmounts immediately on close (no exit transition). [`BottomSheet`](#bottomsheet) is the opposite: it stays mounted through its exit transition before the nodes are removed. If you need a closing animation, use `BottomSheet`.
597
+ > **Note animation is mode-dependent.** `modal` mode is **enter-only**: its content unmounts immediately on close (no exit transition). `sheet` mode mirrors BottomSheet's presence model instead — it plays a **slide-down exit transition** and, with `unmountOnExit`, removes its nodes only after that transition finishes.
598
+
599
+ > **Note — why sheet mode is not an Ark modal (and what that implies).** On iOS in a standalone PWA (`viewport-fit=cover` + `black-translucent` status bar), an Ark modal's body scroll-lock makes WebKit stop extending the layout viewport under the status bar, shrinking `100dvh`/`innerHeight` by ~47px so fixed surfaces bottom out above the real screen bottom (a dark gap under the panel). BottomSheet (non-modal, no scroll lock) never shrinks and is the agreed standard for flush-to-bottom overlays — so `sheet` mode is implemented as a non-modal overlay to match it. Consequently sheet mode ships the **essential layer only**: Escape / opt-in outside-pointer dismissal (both as cancel), focus restore on close, `aria-labelledby`, and background `inert` while open. It has **no focus trap and no body scroll-lock**, and the page behind it can still scroll. If you need a real focus trap, use `modal` mode (full Ark modal semantics).
595
600
 
596
601
  | Prop | Type | Default | Description |
597
602
  |---|---|---|---|
@@ -610,7 +615,7 @@ Passthrough: Ark `Root` props (`value`, `min`, `max`, …).
610
615
  | `onConfirm` | `() => void` | — | Confirm callback. |
611
616
  | `loading` | `boolean` | `false` | Confirm button shows a spinner. |
612
617
  | `intent` | `string` | `'default'` | Confirm button intent (`'default'`, `'neutral'`, `'danger'`). |
613
- | `closeable` | `boolean` | `true` | Show the close (✕) trigger. |
618
+ | `closeable` | `boolean` | `true` | Show the close (✕) trigger. Modal mode only — sheet mode never renders a ✕. |
614
619
  | `lazyMount` | `boolean` | `true` | Mount content lazily. |
615
620
  | `unmountOnExit` | `boolean` | `true` | Unmount content when closed. |
616
621
  | `closeOnInteractOutside` | `boolean` | `false` | Close when clicking outside. |
@@ -713,7 +718,7 @@ A free-form presentation layer: a backdrop, an optional drag grabber, and a scro
713
718
 
714
719
  **Exports:** `BottomSheet` · no `.origin`
715
720
 
716
- **Rendered DOM parts:** exactly three — `data-part="backdrop"`, `data-part="grabber"` (only when `draggable`), and `data-part="content"`. With the default `lazyMount: true` + `unmountOnExit: true`, a closed sheet that was never opened renders **zero** nodes; after close it stays mounted only long enough for the exit transition, then unmounts. BottomSheet has no focus trap or Escape-to-close use [`Dialog`](#dialog) when those are required.
721
+ **Rendered DOM parts:** exactly three — `data-part="backdrop"`, `data-part="grabber"` (only when `draggable`), and `data-part="content"`. With the default `lazyMount: true` + `unmountOnExit: true`, a closed sheet that was never opened renders **zero** nodes; after close it stays mounted only long enough for the exit transition, then unmounts. BottomSheet has no focus trap or Escape-to-close. Use [`Dialog`](#dialog) **in `modal` mode** when a focus trap is required (Dialog sheet mode is also trapless); Escape-to-close exists on Dialog in both modes.
717
722
 
718
723
  > **Design decision:** `content` is a pure container — `display: flex; flex-direction: column` only, with **no padding, no margin, and no scrolling**. Spacing and scrolling are entirely consumer-owned: build your own layout inside `children` (the sheet caps height at `70vh`, so a scrollable section can be a `flex: 1; min-height: 0; overflow-y: auto` child).
719
724
  >
@@ -731,7 +736,7 @@ A free-form presentation layer: a backdrop, an optional drag grabber, and a scro
731
736
  | `backdropClassName` | `string` | — | Extra class for the backdrop. |
732
737
  | `backdropStyle` | `object` | — | Extra style for the backdrop. |
733
738
  | `contentClassName` | `string` | — | Extra class for the content area. |
734
- | `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. |
739
+ | `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 (modal-mode) Dialog's focus trap while its sheet is open. |
735
740
  | `class` | `string` | — | Extra class for the sheet. |
736
741
  | `children` | `node` | — | **Required** body content. |
737
742
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plastic-js/tsumiki",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "A UI component library for Plastic JS.",
5
5
  "license": "MIT",
6
6
  "author": "tigre",