@astryxdesign/core 0.7.6-canary.2a905bb → 0.7.6-canary.2fa4cef

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.
Files changed (42) hide show
  1. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheetPanel.js +36 -9
  3. package/dist/BottomSheet/useSheetGestures.d.ts +36 -6
  4. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  5. package/dist/BottomSheet/useSheetGestures.js +67 -10
  6. package/dist/FileInput/FileInput.d.ts.map +1 -1
  7. package/dist/FileInput/FileInput.js +27 -2
  8. package/dist/Layer/useTouchTrigger.d.ts +16 -2
  9. package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
  10. package/dist/Layer/useTouchTrigger.js +56 -7
  11. package/dist/Toast/useToast.d.ts.map +1 -1
  12. package/dist/Toast/useToast.js +8 -2
  13. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  14. package/dist/ToggleButton/ToggleButton.js +8 -2
  15. package/dist/gestures/haptics.d.ts.map +1 -1
  16. package/dist/gestures/haptics.js +1 -5
  17. package/dist/hooks/useClickableContainer.d.ts +24 -4
  18. package/dist/hooks/useClickableContainer.d.ts.map +1 -1
  19. package/dist/hooks/useClickableContainer.js +41 -14
  20. package/dist/theme/Theme.d.ts +10 -0
  21. package/dist/theme/Theme.d.ts.map +1 -1
  22. package/dist/theme/Theme.js +8 -4
  23. package/dist/utils/pressDisabled.d.ts +21 -0
  24. package/dist/utils/pressDisabled.d.ts.map +1 -0
  25. package/dist/utils/pressDisabled.js +32 -0
  26. package/package.json +4 -4
  27. package/src/BottomSheet/BottomSheetPanel.tsx +39 -8
  28. package/src/BottomSheet/useSheetGestures.ts +92 -15
  29. package/src/FileInput/FileInput.tsx +29 -1
  30. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  31. package/src/HoverCard/HoverCard.spec.md +16 -14
  32. package/src/HoverCard/useHoverCard.doc.mjs +1 -1
  33. package/src/Layer/useTouchTrigger.ts +70 -10
  34. package/src/Toast/useToast.tsx +8 -3
  35. package/src/ToggleButton/ToggleButton.tsx +9 -3
  36. package/src/Tooltip/Tooltip.doc.mjs +3 -3
  37. package/src/Tooltip/Tooltip.spec.md +17 -17
  38. package/src/Tooltip/useTooltip.doc.mjs +1 -1
  39. package/src/gestures/haptics.ts +1 -8
  40. package/src/hooks/useClickableContainer.ts +48 -17
  41. package/src/theme/Theme.tsx +8 -4
  42. package/src/utils/pressDisabled.ts +32 -0
@@ -26,7 +26,7 @@ import "../theme/tokens.stylex.js";
26
26
  import { colorVars, fontWeightVars } from "../theme/tokens.stylex.js";
27
27
  import { Button } from "../Button/index.js";
28
28
  import { useToggleButtonGroup } from "./ToggleButtonGroup.js";
29
- import { themeProps } from "../utils/themeProps.js";
29
+ import { themeDataAttributes, themeProps } from "../utils/themeProps.js";
30
30
  import { mergeProps } from "../utils/mergeProps.js";
31
31
 
32
32
  // =============================================================================
@@ -193,7 +193,6 @@ export function ToggleButton({
193
193
  isLoading: isLoading,
194
194
  isInterruptible: true,
195
195
  isIconOnly: isIconOnly,
196
- "aria-pressed": isPressed,
197
196
  icon: resolvedIcon,
198
197
  tooltip: tooltip,
199
198
  ...mergeProps(themeProps('toggle-button', {
@@ -205,6 +204,13 @@ export function ToggleButton({
205
204
  onClick: handleClick,
206
205
  clickAction: clickAction,
207
206
  ...props,
207
+ // The pressed state this component owns wins over a caller's
208
+ // same-named attributes (public-component-api INV5).
209
+ "aria-pressed": isPressed,
210
+ ...themeDataAttributes({
211
+ isPressed: isPressed ? 'true' : 'false',
212
+ elevation
213
+ }),
208
214
  children: labelContent
209
215
  });
210
216
  }
@@ -1 +1 @@
1
- {"version":3,"file":"haptics.d.ts","sourceRoot":"","sources":["../../src/gestures/haptics.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,0BAA0B,CAAC;AAElC,8EAA8E;AAC9E,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAEnE,4CAA4C;AAC5C,wBAAgB,UAAU,IAAI,OAAO,CAIpC;AAED,6EAA6E;AAC7E,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,QAAQ,GAAG,OAAO,CAQ7D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAK5D;AA0BD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,IAAI,CAuFnE"}
1
+ {"version":3,"file":"haptics.d.ts","sourceRoot":"","sources":["../../src/gestures/haptics.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,0BAA0B,CAAC;AAGlC,8EAA8E;AAC9E,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAEnE,4CAA4C;AAC5C,wBAAgB,UAAU,IAAI,OAAO,CAIpC;AAED,6EAA6E;AAC7E,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,QAAQ,GAAG,OAAO,CAQ7D;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAK5D;AAkBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,IAAI,CAuFnE"}
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { HAPTIC_VIBRATION_PATTERNS } from "../motion/uikit/gestures.js";
38
+ import { isDisabledControl } from "../utils/pressDisabled.js";
38
39
 
39
40
  /** Marks the hidden switch, so click handlers can recognize and ignore it. */
40
41
  export const HAPTIC_SWITCH_ATTRIBUTE = 'data-astryx-haptic-switch';
@@ -69,11 +70,6 @@ export function playHaptic(kind) {
69
70
  const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;' + 'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;' + 'opacity:0;pointer-events:none;';
70
71
  let nextLabelId = 0;
71
72
 
72
- /** A native disabled control, or one marked `aria-disabled`. */
73
- function isDisabledControl(control) {
74
- return control.matches(':disabled') || control.getAttribute('aria-disabled') === 'true';
75
- }
76
-
77
73
  /** Whether `input` is a switch checkbox, which plays the tap haptic itself. */
78
74
  function isSwitch(input) {
79
75
  return input.type === 'checkbox' && input.hasAttribute('switch');
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @file useClickableContainer.ts
3
3
  * @input Container ref, interactive element ref, click/href handlers
4
- * @output onClick and onMouseUp handlers for the container; INTERACTIVE_SELECTORS list
4
+ * @output onClick and onMouseUp handlers for the container; INTERACTIVE_SELECTORS list;
5
+ * hasInteractiveAncestor and the shared innermostInteractive walk
5
6
  * @position Core hook for clickable containers that safely handle nested interactive elements
6
7
  *
7
8
  * Solves the "nested interactive elements" problem: when a card is clickable
@@ -16,6 +17,12 @@
16
17
  * - /packages/core/src/hooks/index.ts (export)
17
18
  */
18
19
  import { type RefObject, type MouseEvent } from 'react';
20
+ /**
21
+ * The attribute this hook sets on the container it makes clickable. A click
22
+ * inside a nested marked container belongs to that container (see
23
+ * INTERACTIVE_SELECTORS).
24
+ */
25
+ export declare const PRESSABLE_CONTAINER_ATTRIBUTE = "data-pressable-container";
19
26
  /**
20
27
  * Canonical list of interactive element selectors — native controls plus
21
28
  * role-based interactive elements. Clicks on these (or their descendants)
@@ -29,9 +36,22 @@ import { type RefObject, type MouseEvent } from 'react';
29
36
  */
30
37
  export declare const INTERACTIVE_SELECTORS: string;
31
38
  /**
32
- * Check whether an element has an interactive ancestor between it and the root.
33
- * If the click target is inside a nested button/link/etc., we should NOT
34
- * handle it at the container level.
39
+ * Whether this element is interactive by INTERACTIVE_SELECTORS: a control,
40
+ * link, editor, or nested clickable container, and not marked read-only.
41
+ */
42
+ export declare function matchesInteractiveSelectors(element: Element): boolean;
43
+ /**
44
+ * The innermost element, from `el` up to (not including) `rootEl` or the
45
+ * document body, that `isInteractive` accepts: by default the
46
+ * INTERACTIVE_SELECTORS match. The one walk every "does this event belong
47
+ * to a control" check shares; a hover layer's tap passes its own test
48
+ * (useTouchTrigger's `tapLandsOnDisabledControl`).
49
+ */
50
+ export declare function innermostInteractive(el: Element, rootEl: Element | null, isInteractive?: (element: Element) => boolean): Element | null;
51
+ /**
52
+ * Check whether an element has an interactive ancestor between it and the
53
+ * root (the element itself included). If the click target is inside a nested
54
+ * button/link/etc., we should NOT handle it at the container level.
35
55
  *
36
56
  * Exported for containers that cannot use the hook itself — a `<tr>` whose
37
57
  * props are assembled inside a Table plugin's `transformBodyRow`, for
@@ -1 +1 @@
1
- {"version":3,"file":"useClickableContainer.d.ts","sourceRoot":"","sources":["../../src/hooks/useClickableContainer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAC,MAAM,OAAO,CAAC;AAG9E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAmBvB,CAAC;AAIZ;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAY5E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CASvD;AAED,MAAM,WAAW,4BAA4B;IAC3C,yCAAyC;IACzC,YAAY,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC5C,mEAAmE;IACnE,cAAc,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC/C,oBAAoB;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IACnD;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kBAAkB;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IAClD,SAAS,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,YAAY,EACZ,cAAc,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EACJ,MAAM,EACN,QAAgB,GACjB,EAAE,4BAA4B,GAAG,wBAAwB,CAmHzD"}
1
+ {"version":3,"file":"useClickableContainer.d.ts","sourceRoot":"","sources":["../../src/hooks/useClickableContainer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAC,MAAM,OAAO,CAAC;AAG9E;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,6BAA6B,CAAC;AAExE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAmBvB,CAAC;AAIZ;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAKrE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,OAAO,EACX,MAAM,EAAE,OAAO,GAAG,IAAI,EACtB,aAAa,GAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAqC,GACzE,OAAO,GAAG,IAAI,CAShB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAE5E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CASvD;AAED,MAAM,WAAW,4BAA4B;IAC3C,yCAAyC;IACzC,YAAY,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC5C,mEAAmE;IACnE,cAAc,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC/C,oBAAoB;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IACnD;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kBAAkB;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;IAClD,SAAS,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,YAAY,EACZ,cAAc,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EACJ,MAAM,EACN,QAAgB,GACjB,EAAE,4BAA4B,GAAG,wBAAwB,CAmHzD"}
@@ -5,7 +5,8 @@
5
5
  /**
6
6
  * @file useClickableContainer.ts
7
7
  * @input Container ref, interactive element ref, click/href handlers
8
- * @output onClick and onMouseUp handlers for the container; INTERACTIVE_SELECTORS list
8
+ * @output onClick and onMouseUp handlers for the container; INTERACTIVE_SELECTORS list;
9
+ * hasInteractiveAncestor and the shared innermostInteractive walk
9
10
  * @position Core hook for clickable containers that safely handle nested interactive elements
10
11
  *
11
12
  * Solves the "nested interactive elements" problem: when a card is clickable
@@ -22,6 +23,13 @@
22
23
  import { useCallback, useEffect } from 'react';
23
24
  import { isSafeUrl } from "../utils/safeUrl.js";
24
25
 
26
+ /**
27
+ * The attribute this hook sets on the container it makes clickable. A click
28
+ * inside a nested marked container belongs to that container (see
29
+ * INTERACTIVE_SELECTORS).
30
+ */
31
+ export const PRESSABLE_CONTAINER_ATTRIBUTE = 'data-pressable-container';
32
+
25
33
  /**
26
34
  * Canonical list of interactive element selectors — native controls plus
27
35
  * role-based interactive elements. Clicks on these (or their descendants)
@@ -33,13 +41,39 @@ import { isSafeUrl } from "../utils/safeUrl.js";
33
41
  * consumers that need focusable elements must additionally exclude
34
42
  * unfocusable/disabled targets (e.g. `[tabindex="-1"]`, `:disabled`).
35
43
  */
36
- export const INTERACTIVE_SELECTORS = ['button', 'a', 'input', 'select', 'textarea', '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="tab"]', '[role="menuitem"]', '[role="option"]', '[role="combobox"]', '[role="listbox"]', '[role="slider"]', '[role="spinbutton"]', '[data-pressable-container]'].join(',');
44
+ export const INTERACTIVE_SELECTORS = ['button', 'a', 'input', 'select', 'textarea', '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="tab"]', '[role="menuitem"]', '[role="option"]', '[role="combobox"]', '[role="listbox"]', '[role="slider"]', '[role="spinbutton"]', `[${PRESSABLE_CONTAINER_ATTRIBUTE}]`].join(',');
37
45
  const NON_INTERACTIVE_SELECTORS = '[aria-readonly="true"]';
38
46
 
39
47
  /**
40
- * Check whether an element has an interactive ancestor between it and the root.
41
- * If the click target is inside a nested button/link/etc., we should NOT
42
- * handle it at the container level.
48
+ * Whether this element is interactive by INTERACTIVE_SELECTORS: a control,
49
+ * link, editor, or nested clickable container, and not marked read-only.
50
+ */
51
+ export function matchesInteractiveSelectors(element) {
52
+ return element.matches(INTERACTIVE_SELECTORS) && !element.matches(NON_INTERACTIVE_SELECTORS);
53
+ }
54
+
55
+ /**
56
+ * The innermost element, from `el` up to (not including) `rootEl` or the
57
+ * document body, that `isInteractive` accepts: by default the
58
+ * INTERACTIVE_SELECTORS match. The one walk every "does this event belong
59
+ * to a control" check shares; a hover layer's tap passes its own test
60
+ * (useTouchTrigger's `tapLandsOnDisabledControl`).
61
+ */
62
+ export function innermostInteractive(el, rootEl, isInteractive = matchesInteractiveSelectors) {
63
+ let current = el;
64
+ while (current != null && current !== rootEl && current !== document.body) {
65
+ if (isInteractive(current)) {
66
+ return current;
67
+ }
68
+ current = current.parentElement;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Check whether an element has an interactive ancestor between it and the
75
+ * root (the element itself included). If the click target is inside a nested
76
+ * button/link/etc., we should NOT handle it at the container level.
43
77
  *
44
78
  * Exported for containers that cannot use the hook itself — a `<tr>` whose
45
79
  * props are assembled inside a Table plugin's `transformBodyRow`, for
@@ -48,14 +82,7 @@ const NON_INTERACTIVE_SELECTORS = '[aria-readonly="true"]';
48
82
  * instead of a short hand-rolled selector list per caller.
49
83
  */
50
84
  export function hasInteractiveAncestor(el, rootEl) {
51
- let current = el;
52
- while (current != null && current !== rootEl && current !== document.body) {
53
- if (current.matches(INTERACTIVE_SELECTORS) && !current.matches(NON_INTERACTIVE_SELECTORS)) {
54
- return true;
55
- }
56
- current = current.parentElement;
57
- }
58
- return false;
85
+ return innermostInteractive(el, rootEl) != null;
59
86
  }
60
87
 
61
88
  /**
@@ -115,7 +142,7 @@ export function useClickableContainer({
115
142
  useEffect(() => {
116
143
  const el = containerRef.current;
117
144
  if (el) {
118
- el.setAttribute('data-pressable-container', 'true');
145
+ el.setAttribute(PRESSABLE_CONTAINER_ATTRIBUTE, 'true');
119
146
  }
120
147
  }, [containerRef]);
121
148
  const onClick = useCallback(event => {
@@ -44,6 +44,16 @@ interface ThemeProps {
44
44
  /** Children to render */
45
45
  children: React.ReactNode;
46
46
  }
47
+ /**
48
+ * True below a Theme; the root Theme (no parent context) syncs data-theme
49
+ * to <html>. A detached React root that conceptually sits inside the app
50
+ * (useToast's fallback viewport) provides it as `true` itself, so a Theme
51
+ * rendered in toast content themes its subtree without claiming <html> as
52
+ * if it were the app's root Theme.
53
+ * @internal Not exported from the theme barrel; useToast imports it from
54
+ * './theme/Theme' directly.
55
+ */
56
+ export declare const ThemeNestingContext: React.Context<boolean>;
47
57
  /**
48
58
  * Theme provider component
49
59
  *
@@ -1 +1 @@
1
- {"version":3,"file":"Theme.d.ts","sourceRoot":"","sources":["../../src/theme/Theme.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAgD,MAAM,OAAO,CAAC;AAGrE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,EAAmB,KAAK,YAAY,EAAC,MAAM,eAAe,CAAC;AAMlE;;GAEG;AACH,UAAU,UAAU;IAClB,+BAA+B;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,kDAAkD;IAClD,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,yBAAyB;IACzB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AA4KD;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,EACpB,KAAK,EACL,IAAe,EACf,QAAQ,GACT,EAAE,UAAU,GAAG,KAAK,CAAC,YAAY,CA+BjC;yBAnCe,KAAK"}
1
+ {"version":3,"file":"Theme.d.ts","sourceRoot":"","sources":["../../src/theme/Theme.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAgD,MAAM,OAAO,CAAC;AAGrE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,EAAmB,KAAK,YAAY,EAAC,MAAM,eAAe,CAAC;AAMlE;;GAEG;AACH,UAAU,UAAU;IAClB,+BAA+B;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,kDAAkD;IAClD,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,yBAAyB;IACzB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AA0BD;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,wBAA6B,CAAC;AA6I9D;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,EACpB,KAAK,EACL,IAAe,EACf,QAAQ,GACT,EAAE,UAAU,GAAG,KAAK,CAAC,YAAY,CA+BjC;yBAnCe,KAAK"}
@@ -78,11 +78,15 @@ const wrapperStyles = {
78
78
  // =============================================================================
79
79
 
80
80
  /**
81
- * Context to detect whether this Theme is nested inside another.
82
- * The root provider (no parent context) syncs data-theme to <html>.
83
- * @internal
81
+ * True below a Theme; the root Theme (no parent context) syncs data-theme
82
+ * to <html>. A detached React root that conceptually sits inside the app
83
+ * (useToast's fallback viewport) provides it as `true` itself, so a Theme
84
+ * rendered in toast content themes its subtree without claiming <html> as
85
+ * if it were the app's root Theme.
86
+ * @internal Not exported from the theme barrel; useToast imports it from
87
+ * './theme/Theme' directly.
84
88
  */
85
- const ThemeNestingContext = /*#__PURE__*/React.createContext(false);
89
+ export const ThemeNestingContext = /*#__PURE__*/React.createContext(false);
86
90
  ThemeNestingContext.displayName = 'ThemeNestingContext';
87
91
 
88
92
  // =============================================================================
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file pressDisabled.ts
3
+ * @input An element
4
+ * @output `isDisabledControl`: whether an element is a disabled control, so a
5
+ * press on it performs no action
6
+ * @position The one disabled-control check that press feedback
7
+ * (../gestures/haptics) and the hover layers' touch trigger
8
+ * (../Layer/useTouchTrigger) share, so the two never disagree about a
9
+ * control. It lives outside the gesture code, which a public import that
10
+ * uses no gesture never carries (architecture:react-component-runtime
11
+ * INV13).
12
+ */
13
+ /**
14
+ * Whether `element` is a disabled control: `aria-disabled="true"`, or a form
15
+ * control the browser counts as `:disabled` (a disabled fieldset's controls
16
+ * included). A disabled control performs no action, so it gives no press
17
+ * feedback (spec:AST-094 FR23), and a tap on it opens its reason instead
18
+ * (component:Tooltip FR5).
19
+ */
20
+ export declare function isDisabledControl(element: Element): boolean;
21
+ //# sourceMappingURL=pressDisabled.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pressDisabled.d.ts","sourceRoot":"","sources":["../../src/utils/pressDisabled.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAS3D"}
@@ -0,0 +1,32 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file pressDisabled.ts
5
+ * @input An element
6
+ * @output `isDisabledControl`: whether an element is a disabled control, so a
7
+ * press on it performs no action
8
+ * @position The one disabled-control check that press feedback
9
+ * (../gestures/haptics) and the hover layers' touch trigger
10
+ * (../Layer/useTouchTrigger) share, so the two never disagree about a
11
+ * control. It lives outside the gesture code, which a public import that
12
+ * uses no gesture never carries (architecture:react-component-runtime
13
+ * INV13).
14
+ */
15
+
16
+ /**
17
+ * Whether `element` is a disabled control: `aria-disabled="true"`, or a form
18
+ * control the browser counts as `:disabled` (a disabled fieldset's controls
19
+ * included). A disabled control performs no action, so it gives no press
20
+ * feedback (spec:AST-094 FR23), and a tap on it opens its reason instead
21
+ * (component:Tooltip FR5).
22
+ */
23
+ export function isDisabledControl(element) {
24
+ if (element.getAttribute('aria-disabled') === 'true') {
25
+ return true;
26
+ }
27
+ try {
28
+ return element.matches(':disabled');
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.6-canary.2a905bb",
3
+ "version": "0.7.6-canary.2fa4cef",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -860,9 +860,9 @@
860
860
  "react-dom": ">=19.0.0"
861
861
  },
862
862
  "devDependencies": {
863
- "@astryxdesign/a11y-spec": "0.7.6-canary.2a905bb",
864
- "@astryxdesign/cli": "0.7.6-canary.2a905bb",
865
- "@astryxdesign/interaction-harness": "0.7.6-canary.2a905bb",
863
+ "@astryxdesign/a11y-spec": "0.7.6-canary.2fa4cef",
864
+ "@astryxdesign/cli": "0.7.6-canary.2fa4cef",
865
+ "@astryxdesign/interaction-harness": "0.7.6-canary.2fa4cef",
866
866
  "@babel/cli": "^7.29.7",
867
867
  "@babel/core": "^7.29.7",
868
868
  "@babel/preset-react": "^7.29.7",
@@ -71,7 +71,11 @@ import {
71
71
  type BottomSheetSnapPoint,
72
72
  } from './snapOffsets';
73
73
  import {useMobileKeyboard} from './useMobileKeyboard';
74
- import {useSheetGestures} from './useSheetGestures';
74
+ import {
75
+ scrollPreservationPadding,
76
+ sheetLayoutHeight,
77
+ useSheetGestures,
78
+ } from './useSheetGestures';
75
79
 
76
80
  const HEIGHT_BUDGETS = {
77
81
  hug: '92dvh',
@@ -592,6 +596,8 @@ export function BottomSheetPanel({
592
596
  ignoredSnapPointsMessage !== '',
593
597
  );
594
598
 
599
+ // A caller's height owns the element's height: the drag never writes it.
600
+ const consumerHeight = style?.height;
595
601
  const {
596
602
  contentProps,
597
603
  handleProps,
@@ -616,6 +622,7 @@ export function BottomSheetPanel({
616
622
  onDismiss,
617
623
  snapHeights,
618
624
  onScrimOpacity,
625
+ hasFixedHeight: consumerHeight != null,
619
626
  });
620
627
 
621
628
  const {getViewportProps, getContentProps} = useScrollableArea({
@@ -758,6 +765,8 @@ export function BottomSheetPanel({
758
765
  // transform — the layout height changes at rest, in one reconciling render
759
766
  // whose visible geometry is identical (see useSheetGestures).
760
767
  // - dragging above the base restores the full height below the viewport
768
+ // (the move that crosses writes that height to the element itself,
769
+ // so this render only catches up to it)
761
770
  // - a peek settles with layout 0, so it slides rather than reflowing
762
771
  const layoutOffset = isDragging
763
772
  ? dragOffset < settledOffset
@@ -774,7 +783,7 @@ export function BottomSheetPanel({
774
783
  const isTraveling = isDragging || settlingLayoutOffset != null;
775
784
  resizedHeight =
776
785
  layoutOffset > 0 || isTraveling
777
- ? `${Math.max(0, sheetHeight - Math.max(0, layoutOffset))}px`
786
+ ? sheetLayoutHeight(sheetHeight, layoutOffset)
778
787
  : undefined;
779
788
  gestureTransform =
780
789
  translation !== 0 ? `translateY(${translation}px)` : undefined;
@@ -791,6 +800,9 @@ export function BottomSheetPanel({
791
800
  ? retainedTransform
792
801
  : undefined;
793
802
  const consumerTransform = style?.transform;
803
+ const hostHeight =
804
+ isInteractive || isRetained || isClosing ? resizedHeight : undefined;
805
+ const bodyEndPadding = scrollPreservationPadding(scrollPreservationInset);
794
806
  // The transform is written to the element here, not rendered through the
795
807
  // style prop. A drag writes it straight to the element once per input
796
808
  // sample (useSheetGestures) and renders nothing in between, so React never
@@ -799,20 +811,39 @@ export function BottomSheetPanel({
799
811
  // the finger left it. Written after every commit instead, outside a drag,
800
812
  // the resting value lands in the same style update as the restored
801
813
  // transition, so a settle or a dismissal animates from the live position.
814
+ //
815
+ // The height and the scrolling area's end inset are rendered through props,
816
+ // but a drag that crosses its base detent writes them to the elements too.
817
+ // When the release is processed before that move's render commits, React
818
+ // compares the resting values with the ones it committed before the drag,
819
+ // finds them unchanged and writes nothing, so the sheet would stay drawn at
820
+ // the drag's height. They are written here after every commit, outside a
821
+ // drag, for the same reason as the transform. A caller's height is left to
822
+ // the style prop: the drag does not write over it, so there is nothing to
823
+ // restore.
802
824
  useLayoutEffect(() => {
803
- if (isDragging || consumerTransform != null) {
825
+ if (isDragging) {
804
826
  return;
805
827
  }
806
828
  const element = elementRef.current;
807
829
  if (element != null) {
808
- element.style.transform = sheetTransform ?? '';
830
+ if (consumerTransform == null) {
831
+ element.style.transform = sheetTransform ?? '';
832
+ }
833
+ if (consumerHeight == null) {
834
+ element.style.height = hostHeight ?? '';
835
+ }
836
+ }
837
+ const body = bodyElementRef.current;
838
+ if (body != null) {
839
+ body.style.paddingBlockEnd = bodyEndPadding ?? '';
809
840
  }
810
841
  });
811
842
  const gestureStyle = {
812
843
  transition: contentProps.style.transition,
813
844
  touchAction: contentProps.style.touchAction,
814
845
  overscrollBehavior: contentProps.style.overscrollBehavior,
815
- height: resizedHeight,
846
+ height: hostHeight,
816
847
  };
817
848
  return (
818
849
  <div
@@ -839,7 +870,7 @@ export function BottomSheetPanel({
839
870
  ...(isInteractive
840
871
  ? gestureStyle
841
872
  : isRetained || isClosing
842
- ? {height: resizedHeight}
873
+ ? {height: hostHeight}
843
874
  : {}),
844
875
  ...style,
845
876
  },
@@ -860,8 +891,8 @@ export function BottomSheetPanel({
860
891
  styles.body,
861
892
  height === 'tall' && styles.tallKeyboardBody,
862
893
  ),
863
- scrollPreservationInset > 0
864
- ? {style: {paddingBlockEnd: `${scrollPreservationInset}px`}}
894
+ bodyEndPadding != null
895
+ ? {style: {paddingBlockEnd: bodyEndPadding}}
865
896
  : {},
866
897
  ),
867
898
  }),
@@ -5,7 +5,8 @@
5
5
  /**
6
6
  * @file useSheetGestures.ts
7
7
  * @input Uses React (useCallback, useEffect, useMemo, useRef, useState)
8
- * @output Exports useSheetGestures hook and its option/result types
8
+ * @output Exports useSheetGestures hook, its option/result types, and the
9
+ * height and end-inset formats a host renders for the layout split
9
10
  * @position Internal to BottomSheet; not exported from the core entry point
10
11
  *
11
12
  * Drag + snap machinery for the bottom sheet. Tracks a pointer drag down the
@@ -26,10 +27,18 @@
26
27
  * A live drag writes that transform straight to the sheet element, once per
27
28
  * input sample, and renders nothing: React state changes when the drag begins
28
29
  * and ends, and in between only when the layout split changes (a drag that
29
- * crosses above its base detent restores the full height). The live offset
30
- * is readable through `activeOffsetRef`. The host owns the element's
31
- * transform outside a drag and writes the resting value after each commit,
32
- * so a settle animates from wherever the finger left the sheet.
30
+ * crosses above its base detent restores the full height). That crossing
31
+ * changes three things that only look right together: the transform, the
32
+ * sheet's height and the scrolling area's end inset. The move writes all
33
+ * three to the elements in the same task, so the browser paints them in one
34
+ * frame, and the render it schedules catches the host up to the same values
35
+ * whenever it commits; it never decides what the next frame shows. The live
36
+ * offset is readable through `activeOffsetRef`. The host owns the element's
37
+ * transform, height and end inset outside a drag and writes their resting
38
+ * values after each commit (a host with a fixed height of its own keeps that
39
+ * height, and the drag never writes it), so a settle animates from wherever the finger
40
+ * left the sheet, and a release processed before the crossing move's render
41
+ * commits still leaves the sheet drawn at its resting detent.
33
42
  *
34
43
  * Those are pixels, and the stops behind them are relative to the viewport, so
35
44
  * a sheet at rest re-resolves its detent on `resize` / `orientationchange`,
@@ -206,6 +215,28 @@ function preservationInsetForOffset(
206
215
  return Math.max(0, baseOffset - targetOffset - naturalEndGap);
207
216
  }
208
217
 
218
+ /**
219
+ * The sheet's height, in px, while it is in motion or rests with a layout
220
+ * split: the fully open height less the part the scrolling area gives up.
221
+ * The host renders it and a live drag writes it; one format keeps the two
222
+ * identical, so the render that follows a written value changes nothing.
223
+ */
224
+ export function sheetLayoutHeight(
225
+ sheetHeight: number,
226
+ layoutOffset: number,
227
+ ): string {
228
+ return `${Math.max(0, sheetHeight - Math.max(0, layoutOffset))}px`;
229
+ }
230
+
231
+ /**
232
+ * The scrolling area's end padding that preserves its scroll position across
233
+ * a height change, or nothing when there is none to add. Shared by the host's
234
+ * render and a live drag's write for the same reason as sheetLayoutHeight.
235
+ */
236
+ export function scrollPreservationPadding(inset: number): string | undefined {
237
+ return inset > 0 ? `${inset}px` : undefined;
238
+ }
239
+
209
240
  function renderedBlockEndPadding(element: HTMLElement): number {
210
241
  const value = Number.parseFloat(getComputedStyle(element).paddingBlockEnd);
211
242
  return Number.isFinite(value) ? value : 0;
@@ -368,6 +399,14 @@ export interface UseSheetGesturesOptions {
368
399
  * on the dismiss overshoot. Lets the owner mirror it onto the scrim.
369
400
  */
370
401
  onScrimOpacity?: (opacity: number) => void;
402
+ /**
403
+ * Whether the host draws the sheet at a fixed height of its own (a caller's
404
+ * height) rather than the layout-split height. A drag across the base
405
+ * detent then writes only the transform and the end inset, and leaves the
406
+ * element's height to the host.
407
+ * @default false
408
+ */
409
+ hasFixedHeight?: boolean;
371
410
  }
372
411
 
373
412
  export interface SheetContentProps {
@@ -508,6 +547,7 @@ export function useSheetGestures({
508
547
  snapHeights,
509
548
  onSnap,
510
549
  onScrimOpacity,
550
+ hasFixedHeight = false,
511
551
  }: UseSheetGesturesOptions): UseSheetGesturesResult {
512
552
  const [dragOffset, setDragOffset] = useState(0);
513
553
  const [settledOffset, setSettledOffset] = useState(0);
@@ -657,11 +697,13 @@ export function useSheetGestures({
657
697
  const canDismissRef = useRef(canDismiss);
658
698
  const onSnapRef = useRef(onSnap);
659
699
  const onScrimOpacityRef = useRef(onScrimOpacity);
700
+ const hasFixedHeightRef = useRef(hasFixedHeight);
660
701
  useEffect(() => {
661
702
  onDismissRef.current = onDismiss;
662
703
  canDismissRef.current = canDismiss;
663
704
  onSnapRef.current = onSnap;
664
705
  onScrimOpacityRef.current = onScrimOpacity;
706
+ hasFixedHeightRef.current = hasFixedHeight;
665
707
  });
666
708
 
667
709
  // Live drag bookkeeping (refs so pointermove doesn't churn renders).
@@ -954,6 +996,31 @@ export function useSheetGestures({
954
996
  [],
955
997
  );
956
998
 
999
+ // A live drag's layout split moved. The transform written beside this call
1000
+ // is only correct on the new split, so the height and end inset that pair
1001
+ // with it go to the elements in the same task. They are the values the host
1002
+ // renders from the state this move also sets, in the same format, so its
1003
+ // commit, whenever it lands, leaves them as they are. If the drag ends
1004
+ // before that commit, the host's post-commit write puts the resting values
1005
+ // back, since React sees no change from what it committed before the drag.
1006
+ // A host with a fixed height of its own renders that height throughout and
1007
+ // never restores a written one, so its height is not written.
1008
+ const writeLiveLayout = useCallback(
1009
+ (sheetHeight: number, layoutOffset: number) => {
1010
+ const element = sheetElRef.current;
1011
+ // Unmeasured, the host renders no height to pair with.
1012
+ if (element != null && sheetHeight > 0 && !hasFixedHeightRef.current) {
1013
+ element.style.height = sheetLayoutHeight(sheetHeight, layoutOffset);
1014
+ }
1015
+ const body = bodyNodeRef.current;
1016
+ if (body != null) {
1017
+ body.style.paddingBlockEnd =
1018
+ scrollPreservationPadding(scrollPreservationInsetRef.current) ?? '';
1019
+ }
1020
+ },
1021
+ [],
1022
+ );
1023
+
957
1024
  const cancelDrag = useCallback(
958
1025
  (target?: HTMLElement) => {
959
1026
  const state = dragStateRef.current;
@@ -1262,23 +1329,28 @@ export function useSheetGestures({
1262
1329
  state.renderedOffset = next;
1263
1330
  state.layoutOffset = layoutOffset;
1264
1331
  activeOffsetRef.current = next;
1332
+ updateScrollPreservationInset(
1333
+ preservationInsetForOffset(
1334
+ state.baseLayoutOffset,
1335
+ layoutOffset,
1336
+ state.naturalEndGap,
1337
+ ),
1338
+ );
1265
1339
  writeLiveTransform(next, layoutOffset);
1266
1340
  if (hasLayoutChanged) {
1267
- // The layout split moved: the host renders the new height, reading
1268
- // the offset it has to pair with it from state.
1341
+ // The layout split moved. The new height and end inset are written
1342
+ // with the transform, in this task: a render scheduled normally can
1343
+ // commit after the next paint, and the transform alone on the old
1344
+ // height would put the sheet the whole split away from the finger
1345
+ // for that frame. The host still renders them from state, reading
1346
+ // the offset it has to pair with the height from here.
1347
+ writeLiveLayout(state.height, layoutOffset);
1269
1348
  setDragOffset(next);
1270
1349
  }
1271
1350
  if (!state.hasTraveled && next !== state.baseOffset) {
1272
1351
  state.hasTraveled = true;
1273
1352
  setIsTraveling(true);
1274
1353
  }
1275
- updateScrollPreservationInset(
1276
- preservationInsetForOffset(
1277
- state.baseLayoutOffset,
1278
- layoutOffset,
1279
- state.naturalEndGap,
1280
- ),
1281
- );
1282
1354
 
1283
1355
  // Mirror the scrim to the live drag: full at/above the mid detent,
1284
1356
  // fading to hidden as it collapses onto the peek detent and through the
@@ -1295,7 +1367,12 @@ export function useSheetGestures({
1295
1367
  scrimOpacityForOffset(next, offsets, dismissOffset, peekOffset),
1296
1368
  );
1297
1369
  },
1298
- [resolveDetents, updateScrollPreservationInset, writeLiveTransform],
1370
+ [
1371
+ resolveDetents,
1372
+ updateScrollPreservationInset,
1373
+ writeLiveLayout,
1374
+ writeLiveTransform,
1375
+ ],
1299
1376
  );
1300
1377
 
1301
1378
  const endDrag = useCallback(