@astryxdesign/core 0.7.2-canary.58d3353 → 0.7.2-canary.9fa2213

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 (32) hide show
  1. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  2. package/dist/HoverCard/useHoverCard.js +10 -2
  3. package/dist/Selector/hooks.d.ts.map +1 -1
  4. package/dist/Selector/hooks.js +6 -2
  5. package/dist/Switch/Switch.d.ts +1 -1
  6. package/dist/Switch/Switch.d.ts.map +1 -1
  7. package/dist/Switch/Switch.js +7 -1
  8. package/dist/Text/useTruncation.d.ts +7 -5
  9. package/dist/Text/useTruncation.d.ts.map +1 -1
  10. package/dist/Text/useTruncation.js +16 -5
  11. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  12. package/dist/i18n/generated-locales/en.generated.js +17 -1
  13. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  14. package/dist/i18n/generated-locales/pseudo.generated.js +17 -1
  15. package/dist/utils/sharedMutationObserver.d.ts +21 -0
  16. package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
  17. package/dist/utils/sharedMutationObserver.js +69 -0
  18. package/locales/en.json +64 -0
  19. package/locales/pseudo.json +48 -0
  20. package/package.json +3 -3
  21. package/src/HoverCard/HoverCard.test.tsx +58 -0
  22. package/src/HoverCard/useHoverCard.tsx +10 -2
  23. package/src/Selector/hooks.ts +6 -2
  24. package/src/Selector/useCombobox.doc.mjs +237 -0
  25. package/src/Switch/Switch.test.tsx +21 -0
  26. package/src/Switch/Switch.tsx +5 -0
  27. package/src/Text/useTruncation.test.tsx +69 -0
  28. package/src/Text/useTruncation.ts +16 -5
  29. package/src/i18n/generated-locales/en.generated.ts +16 -0
  30. package/src/i18n/generated-locales/pseudo.generated.ts +16 -0
  31. package/src/utils/sharedMutationObserver.test.ts +53 -0
  32. package/src/utils/sharedMutationObserver.ts +78 -0
@@ -1 +1 @@
1
- {"version":3,"file":"useHoverCard.d.ts","sourceRoot":"","sources":["../../src/HoverCard/useHoverCard.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,OAAO,CAAC;AAEf,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AA6BlC;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEhE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,iBAAiB,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAC;IAErC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAC;IAErC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEtC;;;;OAIG;IACH,cAAc,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEzC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,EAAE,EAAE,MAAM,CAAC;IAEX;;OAEG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;;;;OAMG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;;;OAOG;IACH,eAAe,EAAE,CACf,QAAQ,EAAE,SAAS,EACnB,KAAK,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,KAC5C,SAAS,CAAC;IAEf;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AAyBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,eAAe,CAic5E"}
1
+ {"version":3,"file":"useHoverCard.d.ts","sourceRoot":"","sources":["../../src/HoverCard/useHoverCard.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,OAAO,CAAC;AAEf,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AA6BlC;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEhE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,iBAAiB,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAC;IAErC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAC;IAErC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEtC;;;;OAIG;IACH,cAAc,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEzC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,EAAE,EAAE,MAAM,CAAC;IAEX;;OAEG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;;;;OAMG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;;;OAOG;IACH,eAAe,EAAE,CACf,QAAQ,EAAE,SAAS,EACnB,KAAK,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,KAC5C,SAAS,CAAC;IAEf;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;IAEjB;;OAEG;IACH,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AAyBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,eAAe,CAyc5E"}
@@ -145,6 +145,14 @@ export function useHoverCard(options = {}) {
145
145
  isHoveringContentRef.current = false;
146
146
  hideLayer();
147
147
  }, [clearTimeouts, hideLayer]);
148
+
149
+ // The imperative hide. A card closed while the pointer rests on it unmounts
150
+ // without a mouseleave, so the content-hover latch is cleared here; left set,
151
+ // it would keep the trigger's next hover-open from closing on leave.
152
+ const hide = useCallback(() => {
153
+ isHoveringContentRef.current = false;
154
+ hideLayer();
155
+ }, [hideLayer]);
148
156
  const {
149
157
  isTouchPointerRef,
150
158
  isTouchInteraction,
@@ -291,7 +299,7 @@ export function useHoverCard(options = {}) {
291
299
  // clear it.
292
300
  const card = typeof document === 'undefined' ? null : document.getElementById(layerId);
293
301
  const hadFocus = card?.contains(document.activeElement) ?? false;
294
- hideLayer();
302
+ hide();
295
303
  if (hadFocus) {
296
304
  isEscapeDismissingRef.current = true;
297
305
  triggerRef.current?.focus();
@@ -448,6 +456,6 @@ export function useHoverCard(options = {}) {
448
456
  isOpen: isLayerOpen,
449
457
  renderHoverCard,
450
458
  show: showLayer,
451
- hide: hideLayer
459
+ hide
452
460
  };
453
461
  }
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/Selector/hooks.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AACrC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAyBhD,UAAU,4BAA4B;IACpC,MAAM,EAAE,OAAO,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC7C,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CAC1C;AAED,UAAU,2BAA2B;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,MAAM,EACN,iBAAiB,EACjB,SAAS,EACT,UAAU,EACV,SAAS,GACV,EAAE,4BAA4B,GAAG,2BAA2B,CAgF5D;AAMD,UAAU,kBAAkB;IAC1B,eAAe,EAAE,kBAAkB,EAAE,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,iBAAiB;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,SAAS,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAC5C,YAAY,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACjD,gBAAgB,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACrE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,eAAe,EACf,KAAK,EACL,UAAkB,EAClB,MAAM,EACN,SAAiB,EACjB,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,SAAS,GACV,EAAE,kBAAkB,GAAG,iBAAiB,CAqOxC"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/Selector/hooks.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AACrC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAyBhD,UAAU,4BAA4B;IACpC,MAAM,EAAE,OAAO,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAC7C,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CAC1C;AAED,UAAU,2BAA2B;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,MAAM,EACN,iBAAiB,EACjB,SAAS,EACT,UAAU,EACV,SAAS,GACV,EAAE,4BAA4B,GAAG,2BAA2B,CAgF5D;AAMD,UAAU,kBAAkB;IAC1B,eAAe,EAAE,kBAAkB,EAAE,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,iBAAiB;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,SAAS,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAC5C,YAAY,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACjD,gBAAgB,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACrE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,eAAe,EACf,KAAK,EACL,UAAkB,EAClB,MAAM,EACN,SAAiB,EACjB,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,SAAS,GACV,EAAE,kBAAkB,GAAG,iBAAiB,CAqOxC"}
@@ -6,8 +6,12 @@
6
6
  * @file hooks.ts
7
7
  * @input Uses untransformed DOM layout geometry from the Selector's outer
8
8
  * anchor, listbox, and selected option
9
- * @output Hooks for Selector
10
- * @position Internal hooks; used by Selector.tsx
9
+ * @output Hooks for Selector: useSelectedItemOffset, and useCombobox (public,
10
+ * exported from the Selector entry point; also used by CommandPalette)
11
+ * @position Selector hooks; used by Selector.tsx and CommandPalette
12
+ *
13
+ * SYNC: When useCombobox's options or return change, update:
14
+ * - /packages/core/src/Selector/useCombobox.doc.mjs
11
15
  */
12
16
  import { useCallback, useState } from 'react';
13
17
  import { useHighlightedOptionScroll } from "../hooks/useHighlightedOptionScroll.js";
@@ -159,7 +159,7 @@ export interface SwitchProps extends Omit<BaseProps, 'onChange'> {
159
159
  * />
160
160
  * ```
161
161
  */
162
- export declare function Switch({ label, isLabelHidden, description, onChange, changeAction, isLoading, value, isDisabled, htmlName, disabledMessage, isOptional, isRequired, onFocus, onBlur, labelIcon, labelTooltip, labelPosition, labelSpacing, status, size, width, xstyle, className, style, ref, 'aria-describedby': ariaDescribedByProp, ...rest }: SwitchProps): import("react").JSX.Element;
162
+ export declare function Switch({ label, isLabelHidden, description, onChange, changeAction, isLoading, value, isDisabled, htmlName, disabledMessage, isOptional, isRequired, onFocus, onBlur, labelIcon, labelTooltip, labelPosition, labelSpacing, status, size, width, xstyle, className, style, ref, 'aria-describedby': ariaDescribedByProp, 'aria-labelledby': ariaLabelledByProp, ...rest }: SwitchProps): import("react").JSX.Element;
163
163
  export declare namespace Switch {
164
164
  var displayName: string;
165
165
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Switch.d.ts","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Switch.d.ts","sourceRoot":"","sources":["../../src/Switch/Switch.tsx"],"names":[],"mappings":"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"}
@@ -292,6 +292,7 @@ export function Switch({
292
292
  style,
293
293
  ref,
294
294
  'aria-describedby': ariaDescribedByProp,
295
+ 'aria-labelledby': ariaLabelledByProp,
295
296
  ...rest
296
297
  }) {
297
298
  const t = useTranslator();
@@ -388,7 +389,12 @@ export function Switch({
388
389
  }
389
390
  },
390
391
  onFocus: onFocus,
391
- onBlur: onBlur,
392
+ onBlur: onBlur
393
+ // A caller's `aria-labelledby` names the control (a visible title
394
+ // elsewhere in the row, for example), so it reaches the input rather
395
+ // than the field wrapper via `...rest`.
396
+ ,
397
+ "aria-labelledby": ariaLabelledByProp,
392
398
  "aria-describedby": ariaDescribedBy,
393
399
  "aria-invalid": status?.type === 'error' ? true : undefined,
394
400
  "aria-busy": isBusy || undefined,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file useTruncation.ts
3
- * @input Uses React hooks, sharedResizeObserver
3
+ * @input Uses React hooks, sharedResizeObserver, sharedMutationObserver
4
4
  * @output Exports useTruncation hook for detecting text overflow
5
5
  * @position Hook; consumed by Text.tsx, Heading.tsx
6
6
  *
@@ -32,10 +32,12 @@ export interface UseTruncationReturn {
32
32
  /**
33
33
  * Hook for detecting text overflow/truncation.
34
34
  *
35
- * Uses a shared ResizeObserver singleton (via observeResize)
36
- * for efficient detection when content or container changes. A single
37
- * ResizeObserver instance is shared across all mounted useTruncation hooks,
38
- * so even hundreds of table cells only create one observer.
35
+ * Uses a shared ResizeObserver singleton (via observeResize) for size
36
+ * changes and a shared MutationObserver singleton (via observeContent) for
37
+ * content changes, so even hundreds of table cells create one observer of
38
+ * each kind. Observing content matters because an element whose text changes
39
+ * without resizing (a fixed-width status line) must still be re-measured for
40
+ * the tooltip text and the truncated state to follow the current content.
39
41
  *
40
42
  * - Single-line: compares scrollWidth > offsetWidth
41
43
  * - Multi-line: uses Range.getBoundingClientRect() to measure actual content
@@ -1 +1 @@
1
- {"version":3,"file":"useTruncation.d.ts","sourceRoot":"","sources":["../../src/Text/useTruncation.ts"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAAgC,KAAK,WAAW,EAAC,MAAM,OAAO,CAAC;AAGtE,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;OAEG;IACH,WAAW,EAAE,OAAO,CAAC;IAErB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CA2ErB"}
1
+ {"version":3,"file":"useTruncation.d.ts","sourceRoot":"","sources":["../../src/Text/useTruncation.ts"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAAgC,KAAK,WAAW,EAAC,MAAM,OAAO,CAAC;AAItE,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC;;OAEG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;OAEG;IACH,WAAW,EAAE,OAAO,CAAC;IAErB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CAmFrB"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTruncation.ts
7
- * @input Uses React hooks, sharedResizeObserver
7
+ * @input Uses React hooks, sharedResizeObserver, sharedMutationObserver
8
8
  * @output Exports useTruncation hook for detecting text overflow
9
9
  * @position Hook; consumed by Text.tsx, Heading.tsx
10
10
  *
@@ -13,13 +13,16 @@
13
13
  */
14
14
  import { useCallback, useRef, useState } from 'react';
15
15
  import { observeResize } from "../utils/sharedResizeObserver.js";
16
+ import { observeContent } from "../utils/sharedMutationObserver.js";
16
17
  /**
17
18
  * Hook for detecting text overflow/truncation.
18
19
  *
19
- * Uses a shared ResizeObserver singleton (via observeResize)
20
- * for efficient detection when content or container changes. A single
21
- * ResizeObserver instance is shared across all mounted useTruncation hooks,
22
- * so even hundreds of table cells only create one observer.
20
+ * Uses a shared ResizeObserver singleton (via observeResize) for size
21
+ * changes and a shared MutationObserver singleton (via observeContent) for
22
+ * content changes, so even hundreds of table cells create one observer of
23
+ * each kind. Observing content matters because an element whose text changes
24
+ * without resizing (a fixed-width status line) must still be re-measured for
25
+ * the tooltip text and the truncated state to follow the current content.
23
26
  *
24
27
  * - Single-line: compares scrollWidth > offsetWidth
25
28
  * - Multi-line: uses Range.getBoundingClientRect() to measure actual content
@@ -44,6 +47,7 @@ export function useTruncation(options) {
44
47
  const [fullText, setFullText] = useState('');
45
48
  const elementRef = useRef(null);
46
49
  const unobserveRef = useRef(null);
50
+ const unobserveContentRef = useRef(null);
47
51
  const checkTruncation = useCallback(element => {
48
52
  if (maxLines === 0) {
49
53
  setIsTruncated(false);
@@ -80,6 +84,8 @@ export function useTruncation(options) {
80
84
  // by element leaves any other observer of the same node untouched.
81
85
  unobserveRef.current?.();
82
86
  unobserveRef.current = null;
87
+ unobserveContentRef.current?.();
88
+ unobserveContentRef.current = null;
83
89
  elementRef.current = element;
84
90
  if (element && maxLines > 0) {
85
91
  // Observe via shared singleton (if available).
@@ -92,6 +98,11 @@ export function useTruncation(options) {
92
98
  } else {
93
99
  checkTruncation(element);
94
100
  }
101
+ // New text in a box that keeps its size fires no resize, so the
102
+ // captured text and truncated state would otherwise stay stale.
103
+ unobserveContentRef.current = observeContent(element, () => {
104
+ checkTruncation(element);
105
+ });
95
106
  } else {
96
107
  setIsTruncated(false);
97
108
  setFullText('');
@@ -1 +1 @@
1
- {"version":3,"file":"en.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/en.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0dpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"en.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/en.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0epD,CAAC;AAEF,eAAe,cAAc,CAAC"}
@@ -436,6 +436,22 @@ const runtimeCatalog = {
436
436
  '@astryx.transferList.announceSearchResults': '{count, number} {count, plural, one {item} other {items}} found.',
437
437
  '@astryx.transferListSelector.apply': 'Apply',
438
438
  '@astryx.transferListSelector.cancel': 'Cancel',
439
- '@astryx.transferListSelector.triggerLabel': '{count, number} selected'
439
+ '@astryx.transferListSelector.triggerLabel': '{count, number} selected',
440
+ '@astryx.emojiPicker.label': 'Emoji',
441
+ '@astryx.emojiPicker.searchLabel': 'Search emoji',
442
+ '@astryx.emojiPicker.frequentlyUsed': 'Frequently used',
443
+ '@astryx.emojiPicker.searchResults': 'Search results',
444
+ '@astryx.emojiPicker.noResults': 'No emoji match “{query}”',
445
+ '@astryx.emojiPicker.loading': 'Loading emoji',
446
+ '@astryx.emojiPicker.loadFailed': "More emoji couldn't be loaded.",
447
+ '@astryx.emojiPicker.groupSmileysEmotion': 'Smileys & emotion',
448
+ '@astryx.emojiPicker.groupPeopleBody': 'People & body',
449
+ '@astryx.emojiPicker.groupAnimalsNature': 'Animals & nature',
450
+ '@astryx.emojiPicker.groupFoodDrink': 'Food & drink',
451
+ '@astryx.emojiPicker.groupTravelPlaces': 'Travel & places',
452
+ '@astryx.emojiPicker.groupActivities': 'Activities',
453
+ '@astryx.emojiPicker.groupObjects': 'Objects',
454
+ '@astryx.emojiPicker.groupSymbols': 'Symbols',
455
+ '@astryx.emojiPicker.groupFlags': 'Flags'
440
456
  };
441
457
  export default runtimeCatalog;
@@ -1 +1 @@
1
- {"version":3,"file":"pseudo.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/pseudo.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmepD,CAAC;AAEF,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"pseudo.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/pseudo.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmfpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
@@ -436,6 +436,22 @@ const runtimeCatalog = {
436
436
  '@astryx.transferList.announceSearchResults': '⟦{count, number} {count, plural, one {item} other {items}} ƒóúñð.⟧',
437
437
  '@astryx.transferListSelector.apply': '⟦Àþþłý⟧',
438
438
  '@astryx.transferListSelector.cancel': '⟦Çàñçéł⟧',
439
- '@astryx.transferListSelector.triggerLabel': '⟦{count, number} šéłéçţéð⟧'
439
+ '@astryx.transferListSelector.triggerLabel': '⟦{count, number} šéłéçţéð⟧',
440
+ '@astryx.emojiPicker.label': '⟦Éɱóĵí⟧',
441
+ '@astryx.emojiPicker.searchLabel': '⟦Šéàřçĥ éɱóĵí⟧',
442
+ '@astryx.emojiPicker.frequentlyUsed': '⟦Ƒřéɋúéñţłý úšéð⟧',
443
+ '@astryx.emojiPicker.searchResults': '⟦Šéàřçĥ řéšúłţš⟧',
444
+ '@astryx.emojiPicker.noResults': '⟦Ñó éɱóĵí ɱàţçĥ “{query}”⟧',
445
+ '@astryx.emojiPicker.loading': '⟦Łóàðíñĝ éɱóĵí⟧',
446
+ '@astryx.emojiPicker.loadFailed': "⟦Ṁóřé éɱóĵí çóúłðñ'ţ ƀé łóàðéð.⟧",
447
+ '@astryx.emojiPicker.groupSmileysEmotion': '⟦Šɱíłéýš & éɱóţíóñ⟧',
448
+ '@astryx.emojiPicker.groupPeopleBody': '⟦Þéóþłé & ƀóðý⟧',
449
+ '@astryx.emojiPicker.groupAnimalsNature': '⟦Àñíɱàłš & ñàţúřé⟧',
450
+ '@astryx.emojiPicker.groupFoodDrink': '⟦Ƒóóð & ðříñķ⟧',
451
+ '@astryx.emojiPicker.groupTravelPlaces': '⟦Ţřàṽéł & þłàçéš⟧',
452
+ '@astryx.emojiPicker.groupActivities': '⟦Àçţíṽíţíéš⟧',
453
+ '@astryx.emojiPicker.groupObjects': '⟦Óƀĵéçţš⟧',
454
+ '@astryx.emojiPicker.groupSymbols': '⟦Šýɱƀółš⟧',
455
+ '@astryx.emojiPicker.groupFlags': '⟦Ƒłàĝš⟧'
440
456
  };
441
457
  export default runtimeCatalog;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file sharedMutationObserver.ts
3
+ * @input MutationObserver API
4
+ * @output Exports observeContent for shared content-change observation
5
+ * @position Internal utility; consumed by useTruncation. The content-change
6
+ * counterpart of sharedResizeObserver: one observer for every element, so a
7
+ * repeated cell or row path does not create one observer per instance.
8
+ */
9
+ type ContentCallback = () => void;
10
+ /**
11
+ * Call `callback` when the element's text or children change (including
12
+ * descendants). Returns an unsubscribe that removes only this registration.
13
+ *
14
+ * MutationObserver has no per-target unobserve, so a released element stays
15
+ * registered with the shared observer until no element remains; its records
16
+ * match no callback and are ignored. The observer disconnects and is released
17
+ * when the last registration goes.
18
+ */
19
+ export declare function observeContent(element: Element, callback: ContentCallback): () => void;
20
+ export {};
21
+ //# sourceMappingURL=sharedMutationObserver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sharedMutationObserver.d.ts","sourceRoot":"","sources":["../../src/utils/sharedMutationObserver.ts"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AAEH,KAAK,eAAe,GAAG,MAAM,IAAI,CAAC;AAuBlC;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,eAAe,GACxB,MAAM,IAAI,CA6BZ"}
@@ -0,0 +1,69 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file sharedMutationObserver.ts
7
+ * @input MutationObserver API
8
+ * @output Exports observeContent for shared content-change observation
9
+ * @position Internal utility; consumed by useTruncation. The content-change
10
+ * counterpart of sharedResizeObserver: one observer for every element, so a
11
+ * repeated cell or row path does not create one observer per instance.
12
+ */
13
+ const OPTIONS = {
14
+ characterData: true,
15
+ childList: true,
16
+ subtree: true
17
+ };
18
+ let observer = null;
19
+ const callbacks = new Map();
20
+ function dispatch(records) {
21
+ // One call per callback per batch, however many records it received. A
22
+ // record's target can be any descendant, so walk up to the observed roots.
23
+ const pending = new Set();
24
+ for (const record of records) {
25
+ for (let node = record.target; node; node = node.parentNode) {
26
+ callbacks.get(node)?.forEach(callback => pending.add(callback));
27
+ }
28
+ }
29
+ pending.forEach(callback => callback());
30
+ }
31
+
32
+ /**
33
+ * Call `callback` when the element's text or children change (including
34
+ * descendants). Returns an unsubscribe that removes only this registration.
35
+ *
36
+ * MutationObserver has no per-target unobserve, so a released element stays
37
+ * registered with the shared observer until no element remains; its records
38
+ * match no callback and are ignored. The observer disconnects and is released
39
+ * when the last registration goes.
40
+ */
41
+ export function observeContent(element, callback) {
42
+ if (typeof MutationObserver === 'undefined') {
43
+ return () => {};
44
+ }
45
+ observer ??= new MutationObserver(dispatch);
46
+ const existing = callbacks.get(element);
47
+ if (existing) {
48
+ existing.add(callback);
49
+ } else {
50
+ callbacks.set(element, new Set([callback]));
51
+ observer.observe(element, OPTIONS);
52
+ }
53
+ let isSubscribed = true;
54
+ return () => {
55
+ if (!isSubscribed) {
56
+ return;
57
+ }
58
+ isSubscribed = false;
59
+ const targetCallbacks = callbacks.get(element);
60
+ targetCallbacks?.delete(callback);
61
+ if (targetCallbacks?.size === 0) {
62
+ callbacks.delete(element);
63
+ }
64
+ if (callbacks.size === 0 && observer) {
65
+ observer.disconnect();
66
+ observer = null;
67
+ }
68
+ };
69
+ }
package/locales/en.json CHANGED
@@ -1718,5 +1718,69 @@
1718
1718
  "@astryx.transferListSelector.triggerLabel": {
1719
1719
  "defaultMessage": "{count, number} selected",
1720
1720
  "description": "Default summary on the closed trigger of a lab TransferListSelector, stating how many options are currently chosen. Consumers usually override this with a domain phrase (e.g. \"7 columns\")."
1721
+ },
1722
+ "@astryx.emojiPicker.label": {
1723
+ "defaultMessage": "Emoji",
1724
+ "description": "Default accessible name of the scrolling list of emoji in a lab EmojiPicker, and the footer text when no emoji is hovered or focused."
1725
+ },
1726
+ "@astryx.emojiPicker.searchLabel": {
1727
+ "defaultMessage": "Search emoji",
1728
+ "description": "Default placeholder and hidden label of the search field of a lab EmojiPicker."
1729
+ },
1730
+ "@astryx.emojiPicker.frequentlyUsed": {
1731
+ "defaultMessage": "Frequently used",
1732
+ "description": "Heading of the first group in a lab EmojiPicker, the host's frequent or recent emoji."
1733
+ },
1734
+ "@astryx.emojiPicker.searchResults": {
1735
+ "defaultMessage": "Search results",
1736
+ "description": "Heading of the single group a lab EmojiPicker shows while its search field has text."
1737
+ },
1738
+ "@astryx.emojiPicker.noResults": {
1739
+ "defaultMessage": "No emoji match “{query}”",
1740
+ "description": "Shown in a lab EmojiPicker when no emoji name or keyword matches the search. `query` is the search text."
1741
+ },
1742
+ "@astryx.emojiPicker.loading": {
1743
+ "defaultMessage": "Loading emoji",
1744
+ "description": "Accessible name of the spinner a lab EmojiPicker shows while its bundled emoji set loads."
1745
+ },
1746
+ "@astryx.emojiPicker.loadFailed": {
1747
+ "defaultMessage": "More emoji couldn't be loaded.",
1748
+ "description": "Shown in a lab EmojiPicker when its bundled emoji set failed to load; the frequent emoji still show."
1749
+ },
1750
+ "@astryx.emojiPicker.groupSmileysEmotion": {
1751
+ "defaultMessage": "Smileys & emotion",
1752
+ "description": "Heading of the Unicode CLDR emoji group of faces and emotions in a lab EmojiPicker."
1753
+ },
1754
+ "@astryx.emojiPicker.groupPeopleBody": {
1755
+ "defaultMessage": "People & body",
1756
+ "description": "Heading of the Unicode CLDR emoji group of people, gestures, and body parts in a lab EmojiPicker."
1757
+ },
1758
+ "@astryx.emojiPicker.groupAnimalsNature": {
1759
+ "defaultMessage": "Animals & nature",
1760
+ "description": "Heading of the Unicode CLDR emoji group of animals and plants in a lab EmojiPicker."
1761
+ },
1762
+ "@astryx.emojiPicker.groupFoodDrink": {
1763
+ "defaultMessage": "Food & drink",
1764
+ "description": "Heading of the Unicode CLDR emoji group of food and drink in a lab EmojiPicker."
1765
+ },
1766
+ "@astryx.emojiPicker.groupTravelPlaces": {
1767
+ "defaultMessage": "Travel & places",
1768
+ "description": "Heading of the Unicode CLDR emoji group of places, vehicles, and weather in a lab EmojiPicker."
1769
+ },
1770
+ "@astryx.emojiPicker.groupActivities": {
1771
+ "defaultMessage": "Activities",
1772
+ "description": "Heading of the Unicode CLDR emoji group of sports, games, and events in a lab EmojiPicker."
1773
+ },
1774
+ "@astryx.emojiPicker.groupObjects": {
1775
+ "defaultMessage": "Objects",
1776
+ "description": "Heading of the Unicode CLDR emoji group of everyday objects in a lab EmojiPicker."
1777
+ },
1778
+ "@astryx.emojiPicker.groupSymbols": {
1779
+ "defaultMessage": "Symbols",
1780
+ "description": "Heading of the Unicode CLDR emoji group of signs and symbols in a lab EmojiPicker."
1781
+ },
1782
+ "@astryx.emojiPicker.groupFlags": {
1783
+ "defaultMessage": "Flags",
1784
+ "description": "Heading of the Unicode CLDR emoji group of flags in a lab EmojiPicker."
1721
1785
  }
1722
1786
  }
@@ -1288,5 +1288,53 @@
1288
1288
  },
1289
1289
  "@astryx.transferListSelector.triggerLabel": {
1290
1290
  "defaultMessage": "⟦{count, number} šéłéçţéð⟧"
1291
+ },
1292
+ "@astryx.emojiPicker.label": {
1293
+ "defaultMessage": "⟦Éɱóĵí⟧"
1294
+ },
1295
+ "@astryx.emojiPicker.searchLabel": {
1296
+ "defaultMessage": "⟦Šéàřçĥ éɱóĵí⟧"
1297
+ },
1298
+ "@astryx.emojiPicker.frequentlyUsed": {
1299
+ "defaultMessage": "⟦Ƒřéɋúéñţłý úšéð⟧"
1300
+ },
1301
+ "@astryx.emojiPicker.searchResults": {
1302
+ "defaultMessage": "⟦Šéàřçĥ řéšúłţš⟧"
1303
+ },
1304
+ "@astryx.emojiPicker.noResults": {
1305
+ "defaultMessage": "⟦Ñó éɱóĵí ɱàţçĥ “{query}”⟧"
1306
+ },
1307
+ "@astryx.emojiPicker.loading": {
1308
+ "defaultMessage": "⟦Łóàðíñĝ éɱóĵí⟧"
1309
+ },
1310
+ "@astryx.emojiPicker.loadFailed": {
1311
+ "defaultMessage": "⟦Ṁóřé éɱóĵí çóúłðñ'ţ ƀé łóàðéð.⟧"
1312
+ },
1313
+ "@astryx.emojiPicker.groupSmileysEmotion": {
1314
+ "defaultMessage": "⟦Šɱíłéýš & éɱóţíóñ⟧"
1315
+ },
1316
+ "@astryx.emojiPicker.groupPeopleBody": {
1317
+ "defaultMessage": "⟦Þéóþłé & ƀóðý⟧"
1318
+ },
1319
+ "@astryx.emojiPicker.groupAnimalsNature": {
1320
+ "defaultMessage": "⟦Àñíɱàłš & ñàţúřé⟧"
1321
+ },
1322
+ "@astryx.emojiPicker.groupFoodDrink": {
1323
+ "defaultMessage": "⟦Ƒóóð & ðříñķ⟧"
1324
+ },
1325
+ "@astryx.emojiPicker.groupTravelPlaces": {
1326
+ "defaultMessage": "⟦Ţřàṽéł & þłàçéš⟧"
1327
+ },
1328
+ "@astryx.emojiPicker.groupActivities": {
1329
+ "defaultMessage": "⟦Àçţíṽíţíéš⟧"
1330
+ },
1331
+ "@astryx.emojiPicker.groupObjects": {
1332
+ "defaultMessage": "⟦Óƀĵéçţš⟧"
1333
+ },
1334
+ "@astryx.emojiPicker.groupSymbols": {
1335
+ "defaultMessage": "⟦Šýɱƀółš⟧"
1336
+ },
1337
+ "@astryx.emojiPicker.groupFlags": {
1338
+ "defaultMessage": "⟦Ƒłàĝš⟧"
1291
1339
  }
1292
1340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.58d3353",
3
+ "version": "0.7.2-canary.9fa2213",
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",
@@ -787,8 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
- "@astryxdesign/a11y-spec": "0.7.2-canary.58d3353",
791
- "@astryxdesign/cli": "0.7.2-canary.58d3353",
790
+ "@astryxdesign/a11y-spec": "0.7.2-canary.9fa2213",
791
+ "@astryxdesign/cli": "0.7.2-canary.9fa2213",
792
792
  "@babel/cli": "^7.29.7",
793
793
  "@babel/core": "^7.29.7",
794
794
  "@babel/preset-react": "^7.29.7",
@@ -109,6 +109,64 @@ describe('useHoverCard identity', () => {
109
109
  });
110
110
  });
111
111
 
112
+ // A card closed while the pointer rests on it unmounts without a mouseleave,
113
+ // so the hook must not keep believing the pointer is over the card: if it
114
+ // does, the next open of the same trigger never closes on leave.
115
+ describe('useHoverCard closing while the pointer is on the card', () => {
116
+ function Harness() {
117
+ const card = useHoverCard({delay: 0, hideDelay: 0});
118
+ return (
119
+ <div>
120
+ <span ref={card.ref}>Trigger</span>
121
+ <output data-testid="state">{card.isOpen ? 'open' : 'closed'}</output>
122
+ {card.renderHoverCard(
123
+ <button type="button" onClick={card.hide}>
124
+ Pick
125
+ </button>,
126
+ )}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ async function openThenRestOnCard() {
132
+ const state = screen.getByTestId('state');
133
+ fireEvent.mouseEnter(screen.getByText('Trigger'));
134
+ await waitFor(() => expect(state).toHaveTextContent('open'));
135
+ const pick = screen.getByRole('button', {name: 'Pick', hidden: true});
136
+ fireEvent.mouseLeave(screen.getByText('Trigger'));
137
+ fireEvent.mouseEnter(pick.parentElement!);
138
+ return {state, pick};
139
+ }
140
+
141
+ async function expectNextOpenClosesOnLeave(state: HTMLElement) {
142
+ const trigger = screen.getByText('Trigger');
143
+ fireEvent.mouseEnter(trigger);
144
+ await waitFor(() => expect(state).toHaveTextContent('open'));
145
+ fireEvent.mouseLeave(trigger);
146
+ await waitFor(() => expect(state).toHaveTextContent('closed'));
147
+ }
148
+
149
+ it('closes the next open on leave after hide() from inside the card', async () => {
150
+ render(<Harness />);
151
+ const {state, pick} = await openThenRestOnCard();
152
+
153
+ fireEvent.click(pick);
154
+ await waitFor(() => expect(state).toHaveTextContent('closed'));
155
+
156
+ await expectNextOpenClosesOnLeave(state);
157
+ });
158
+
159
+ it('closes the next open on leave after Escape dismisses the card', async () => {
160
+ render(<Harness />);
161
+ const {state} = await openThenRestOnCard();
162
+
163
+ fireEvent.keyDown(document.body, {key: 'Escape'});
164
+ await waitFor(() => expect(state).toHaveTextContent('closed'));
165
+
166
+ await expectNextOpenClosesOnLeave(state);
167
+ });
168
+ });
169
+
112
170
  describe('HoverCard', () => {
113
171
  it('renders trigger element', () => {
114
172
  render(
@@ -345,6 +345,14 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
345
345
  hideLayer();
346
346
  }, [clearTimeouts, hideLayer]);
347
347
 
348
+ // The imperative hide. A card closed while the pointer rests on it unmounts
349
+ // without a mouseleave, so the content-hover latch is cleared here; left set,
350
+ // it would keep the trigger's next hover-open from closing on leave.
351
+ const hide = useCallback(() => {
352
+ isHoveringContentRef.current = false;
353
+ hideLayer();
354
+ }, [hideLayer]);
355
+
348
356
  const {
349
357
  isTouchPointerRef,
350
358
  isTouchInteraction,
@@ -508,7 +516,7 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
508
516
  ? null
509
517
  : document.getElementById(layerId);
510
518
  const hadFocus = card?.contains(document.activeElement) ?? false;
511
- hideLayer();
519
+ hide();
512
520
  if (hadFocus) {
513
521
  isEscapeDismissingRef.current = true;
514
522
  triggerRef.current?.focus();
@@ -720,6 +728,6 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
720
728
  isOpen: isLayerOpen,
721
729
  renderHoverCard,
722
730
  show: showLayer,
723
- hide: hideLayer,
731
+ hide,
724
732
  };
725
733
  }
@@ -6,8 +6,12 @@
6
6
  * @file hooks.ts
7
7
  * @input Uses untransformed DOM layout geometry from the Selector's outer
8
8
  * anchor, listbox, and selected option
9
- * @output Hooks for Selector
10
- * @position Internal hooks; used by Selector.tsx
9
+ * @output Hooks for Selector: useSelectedItemOffset, and useCombobox (public,
10
+ * exported from the Selector entry point; also used by CommandPalette)
11
+ * @position Selector hooks; used by Selector.tsx and CommandPalette
12
+ *
13
+ * SYNC: When useCombobox's options or return change, update:
14
+ * - /packages/core/src/Selector/useCombobox.doc.mjs
11
15
  */
12
16
 
13
17
  import {useCallback, useState} from 'react';
@@ -0,0 +1,237 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').HookDoc} */
4
+ export const docs = {
5
+ name: 'useCombobox',
6
+ displayName: 'useCombobox',
7
+ group: 'Selector',
8
+ keywords: [
9
+ 'combobox',
10
+ 'listbox',
11
+ 'select',
12
+ 'dropdown',
13
+ 'highlight',
14
+ 'active option',
15
+ 'aria-activedescendant',
16
+ 'keyboard',
17
+ 'arrow keys',
18
+ 'headless',
19
+ 'apg',
20
+ 'accessibility',
21
+ 'a11y',
22
+ ],
23
+ params: [
24
+ {
25
+ name: 'options',
26
+ type: 'UseComboboxOptions',
27
+ description: 'Configuration object.',
28
+ required: true,
29
+ },
30
+ {
31
+ name: 'options.selectableItems',
32
+ type: 'SelectorOptionData[]',
33
+ description:
34
+ 'The options in the same order as they render. Options with `disabled` cannot be picked and are skipped while moving through the open list; ArrowDown or ArrowUp that opens the list still lands on the first or last option.',
35
+ required: true,
36
+ },
37
+ {
38
+ name: 'options.isOpen',
39
+ type: 'boolean',
40
+ description: 'Whether the list is shown. The caller owns this state.',
41
+ required: true,
42
+ },
43
+ {
44
+ name: 'options.onOpen',
45
+ type: '() => unknown',
46
+ description:
47
+ 'Called to show the list. Return `false` to refuse; the hook then leaves the highlight alone.',
48
+ required: true,
49
+ },
50
+ {
51
+ name: 'options.onClose',
52
+ type: '() => void',
53
+ description:
54
+ 'Called when the hook hides the list; the hook resets the highlight first. When the caller closes the list itself (an outside press, say), it should also call `setHighlightedIndex(-1)`.',
55
+ required: true,
56
+ },
57
+ {
58
+ name: 'options.listboxId',
59
+ type: 'string',
60
+ description:
61
+ "The listbox element's id. Option ids are derived from it through `getItemId`.",
62
+ required: true,
63
+ },
64
+ {
65
+ name: 'options.value',
66
+ type: 'string',
67
+ description:
68
+ 'The selected value. Without search, opening by a press, Enter or Space highlights it, or the first option when nothing is selected; ArrowDown and ArrowUp open on the first and last option.',
69
+ },
70
+ {
71
+ name: 'options.onSelect',
72
+ type: '(value: string) => void',
73
+ description:
74
+ 'Called with the picked value. Picking also closes the list.',
75
+ },
76
+ {
77
+ name: 'options.onClear',
78
+ type: '() => void',
79
+ description:
80
+ 'Clears the value. When provided, Delete or Backspace on the closed control clears it, so clearing is not pointer-only. Ignored while the list is open or with `hasSearch`.',
81
+ },
82
+ {
83
+ name: 'options.hasSearch',
84
+ type: 'boolean',
85
+ description:
86
+ 'Whether the list has its own search field. Space then types instead of picking, opening by a press or Enter does not pre-highlight (ArrowDown and ArrowUp still open on the first and last option), and printable keys on the closed control go to `onSearchSeed`.',
87
+ default: 'false',
88
+ },
89
+ {
90
+ name: 'options.onSearchSeed',
91
+ type: '(char: string) => void',
92
+ description:
93
+ 'With `hasSearch`, receives each printable character except Space typed while the control has focus, opening the list first when it is closed, so type-to-find needs no separate open step.',
94
+ },
95
+ {
96
+ name: 'options.isDisabled',
97
+ type: 'boolean',
98
+ description: 'Whether the control ignores clicks and keys.',
99
+ default: 'false',
100
+ },
101
+ ],
102
+ returns: [
103
+ {
104
+ name: 'highlightedIndex',
105
+ type: 'number',
106
+ description:
107
+ 'Index of the active option, or -1 when none is active. With `hasSearch`, opening by a press or Enter does not set one, so it stays -1 after the hook itself closes the list; ArrowDown and ArrowUp still open on the first or last option. Render it as the active option and name it in `aria-activedescendant` only while it points at a rendered option.',
108
+ },
109
+ {
110
+ name: 'setHighlightedIndex',
111
+ type: '(index: number) => void',
112
+ description:
113
+ 'Sets the active option, for example from a filter that changes the options.',
114
+ },
115
+ {
116
+ name: 'getItemId',
117
+ type: '(index: number) => string',
118
+ description:
119
+ "The id to render on the option at `index`. Set the control's `aria-activedescendant` to `isOpen && highlightedIndex >= 0 && highlightedIndex < selectableItems.length ? getItemId(highlightedIndex) : undefined`, so it never names an option that does not exist, including in an empty list.",
120
+ },
121
+ {
122
+ name: 'onTriggerClick',
123
+ type: '() => void',
124
+ description: 'Toggles the list from a press on the control.',
125
+ },
126
+ {
127
+ name: 'onKeyDown',
128
+ type: '(e: React.KeyboardEvent) => void',
129
+ description:
130
+ 'Key handler for the control that keeps focus. ArrowDown and ArrowUp open the list or move the active option; Enter (and Space without search) picks it or opens the list; Escape and Tab close it; Home, End, PageUp and PageDown jump to the first or last option. An editable control that keeps Home and End for the caret does not pass them to this handler, as CommandPalette does.',
131
+ },
132
+ {
133
+ name: 'onItemSelect',
134
+ type: '(item: SelectorOptionData) => void',
135
+ description: 'Picks an option from a press on it, then closes the list.',
136
+ },
137
+ {
138
+ name: 'onItemMouseEnter',
139
+ type: '(item: SelectorOptionData, index: number) => void',
140
+ description:
141
+ 'Makes the hovered option active without scrolling the list or moving focus.',
142
+ },
143
+ ],
144
+ usage: {
145
+ description:
146
+ 'Tracks the active option of a single-select combobox and handles its keys, while DOM focus stays on the control. It powers Selector and CommandPalette; use it to build a combobox those components do not cover. It renders nothing and sets no attributes: the caller renders the control, the listbox and the options, and wires `role`, `aria-expanded`, `aria-controls` and `aria-activedescendant` from what it returns.',
147
+ bestPractices: [
148
+ {
149
+ guidance: true,
150
+ description:
151
+ "Render each option with `id={getItemId(index)}` and set the control's `aria-activedescendant` only while `isOpen` and `highlightedIndex` points at a rendered option, as CommandPalette does.",
152
+ },
153
+ {
154
+ guidance: true,
155
+ description:
156
+ "For type-to-select without a search field, run useTypeahead's handler first and fall through to this `onKeyDown` when it returns false, as Selector does.",
157
+ },
158
+ {
159
+ guidance: true,
160
+ description:
161
+ 'Announce the number of results through useAnnounce when the options change for a new query.',
162
+ },
163
+ {
164
+ guidance: false,
165
+ description:
166
+ 'Move DOM focus onto the options; the active option is announced through `aria-activedescendant` while focus stays on the control.',
167
+ },
168
+ ],
169
+ },
170
+ relatedComponents: ['Selector', 'CommandPalette'],
171
+ relatedHooks: ['useMultiCombobox', 'useTypeahead', 'useAnnounce'],
172
+ importPath: '@astryxdesign/core/Selector',
173
+ category: 'focus',
174
+ };
175
+
176
+ /** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
177
+ export const docsDense = {
178
+ description:
179
+ 'Single-select combobox state + keys; focus stays on control. Powers Selector / CommandPalette. Renders nothing, sets no attrs: caller renders control / listbox / options and wires role, aria-expanded, aria-controls, aria-activedescendant.',
180
+ paramDescriptions: {
181
+ options: 'config.',
182
+ 'options.selectableItems':
183
+ 'options in render order; disabled ones not pickable, skipped while moving in open list (ArrowDown/Up opening still lands first / last).',
184
+ 'options.isOpen': 'list shown; caller owns state.',
185
+ 'options.onOpen':
186
+ 'show list; return false to refuse (highlight untouched).',
187
+ 'options.onClose':
188
+ 'hook hides list; resets highlight first. caller closing itself (outside press): also setHighlightedIndex(-1).',
189
+ 'options.listboxId': 'listbox id; option ids derive from it (getItemId).',
190
+ 'options.value':
191
+ 'selected value; w/o search, press / Enter / Space opening highlights it (else first); ArrowDown/Up open on first / last.',
192
+ 'options.onSelect': 'called w/ picked value; picking closes.',
193
+ 'options.onClear':
194
+ 'clears value; Delete / Backspace on closed control. ignored when open or hasSearch.',
195
+ 'options.hasSearch':
196
+ 'list has own search field: Space types, press / Enter opening does not pre-highlight (ArrowDown/Up still first / last), printable keys on closed control -> onSearchSeed.',
197
+ 'options.onSearchSeed':
198
+ 'w/ hasSearch, each printable char except Space typed while control focused (opens first if closed).',
199
+ 'options.isDisabled': 'ignore clicks + keys.',
200
+ },
201
+ returnDescriptions: {
202
+ highlightedIndex:
203
+ 'active option index, -1 = none. hasSearch: press / Enter opening sets none (stays -1 after hook closes); ArrowDown/Up still first / last. name in aria-activedescendant only when it points at a rendered option.',
204
+ setHighlightedIndex: 'set active option (e.g. after filtering).',
205
+ getItemId:
206
+ 'id for option at index; aria-activedescendant = isOpen && highlightedIndex >= 0 && highlightedIndex < selectableItems.length ? getItemId(highlightedIndex) : undefined (empty list safe).',
207
+ onTriggerClick: 'toggle list from a press on control.',
208
+ onKeyDown:
209
+ 'control keydown: ArrowDown/Up open or move; Enter (Space w/o search) picks or opens; Escape / Tab close; Home / End / PageUp / PageDown jump. editable control keeping Home / End for caret: do not pass them.',
210
+ onItemSelect: 'pick from a press, then close.',
211
+ onItemMouseEnter: 'hover makes option active; no scroll, no focus move.',
212
+ },
213
+ usage: {
214
+ description:
215
+ 'Headless single-select combobox: active option + keys, focus stays on control. Caller renders + wires ARIA.',
216
+ bestPractices: [
217
+ {
218
+ guidance: true,
219
+ description:
220
+ 'Option id = getItemId(index); aria-activedescendant only while open and highlightedIndex points at a rendered option.',
221
+ },
222
+ {
223
+ guidance: true,
224
+ description:
225
+ 'Type-to-select w/o search: run useTypeahead first, fall through when it returns false.',
226
+ },
227
+ {
228
+ guidance: true,
229
+ description: 'Announce result count via useAnnounce on new query.',
230
+ },
231
+ {
232
+ guidance: false,
233
+ description: 'Move DOM focus onto options; use aria-activedescendant.',
234
+ },
235
+ ],
236
+ },
237
+ };
@@ -862,6 +862,27 @@ describe('Switch', () => {
862
862
  const root = container.querySelector('[data-testid="my-switch"]');
863
863
  expect(root).not.toHaveAttribute('aria-describedby');
864
864
  });
865
+
866
+ it('names the switch control from a caller aria-labelledby', () => {
867
+ const {container} = render(
868
+ <>
869
+ <span id="row-title">Release notes</span>
870
+ <Switch
871
+ label="Pin Release notes"
872
+ isLabelHidden
873
+ value={false}
874
+ onChange={() => {}}
875
+ data-testid="my-switch"
876
+ aria-labelledby="row-title"
877
+ />
878
+ </>,
879
+ );
880
+ const control = screen.getByRole('switch', {name: 'Release notes'});
881
+ expect(control).toHaveAttribute('aria-labelledby', 'row-title');
882
+ // The id reference lives on the control only, not on the field wrapper.
883
+ const root = container.querySelector('[data-testid="my-switch"]');
884
+ expect(root).not.toHaveAttribute('aria-labelledby');
885
+ });
865
886
  });
866
887
 
867
888
  describe('pressed state', () => {
@@ -567,6 +567,7 @@ export function Switch({
567
567
  style,
568
568
  ref,
569
569
  'aria-describedby': ariaDescribedByProp,
570
+ 'aria-labelledby': ariaLabelledByProp,
570
571
  ...rest
571
572
  }: SwitchProps) {
572
573
  const t = useTranslator();
@@ -664,6 +665,10 @@ export function Switch({
664
665
  }}
665
666
  onFocus={onFocus}
666
667
  onBlur={onBlur}
668
+ // A caller's `aria-labelledby` names the control (a visible title
669
+ // elsewhere in the row, for example), so it reaches the input rather
670
+ // than the field wrapper via `...rest`.
671
+ aria-labelledby={ariaLabelledByProp}
667
672
  aria-describedby={ariaDescribedBy}
668
673
  aria-invalid={status?.type === 'error' ? true : undefined}
669
674
  aria-busy={isBusy || undefined}
@@ -0,0 +1,69 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTruncation.test.tsx
5
+ * Tests for useTruncation
6
+ */
7
+
8
+ import {render, screen, waitFor} from '@testing-library/react';
9
+ import {afterEach, describe, it, expect, vi} from 'vitest';
10
+ import {useTruncation} from './useTruncation';
11
+
12
+ function Probe({text}: {text: string}) {
13
+ const truncation = useTruncation({maxLines: 1});
14
+ return (
15
+ <>
16
+ <span ref={truncation.ref}>{text}</span>
17
+ <output data-testid="full-text">{truncation.fullText}</output>
18
+ </>
19
+ );
20
+ }
21
+
22
+ // A status line at a fixed width never resizes when its message changes, so
23
+ // the tooltip text must follow the content, not only the element's size.
24
+ describe('useTruncation', () => {
25
+ it('re-measures when the text changes without a resize', async () => {
26
+ const {rerender} = render(<Probe text="planning the first step" />);
27
+ expect(screen.getByTestId('full-text')).toHaveTextContent(
28
+ 'planning the first step',
29
+ );
30
+
31
+ rerender(<Probe text="writing the second step" />);
32
+
33
+ await waitFor(() =>
34
+ expect(screen.getByTestId('full-text')).toHaveTextContent(
35
+ 'writing the second step',
36
+ ),
37
+ );
38
+ });
39
+
40
+ describe('content observation', () => {
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it('shares one observer across every truncating instance', () => {
46
+ const NativeMutationObserver = globalThis.MutationObserver;
47
+ let created = 0;
48
+ vi.stubGlobal(
49
+ 'MutationObserver',
50
+ class extends NativeMutationObserver {
51
+ constructor(callback: MutationCallback) {
52
+ super(callback);
53
+ created += 1;
54
+ }
55
+ },
56
+ );
57
+
58
+ render(
59
+ <>
60
+ {Array.from({length: 50}, (_, index) => (
61
+ <Probe key={index} text={`cell ${index}`} />
62
+ ))}
63
+ </>,
64
+ );
65
+
66
+ expect(created).toBe(1);
67
+ });
68
+ });
69
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTruncation.ts
7
- * @input Uses React hooks, sharedResizeObserver
7
+ * @input Uses React hooks, sharedResizeObserver, sharedMutationObserver
8
8
  * @output Exports useTruncation hook for detecting text overflow
9
9
  * @position Hook; consumed by Text.tsx, Heading.tsx
10
10
  *
@@ -14,6 +14,7 @@
14
14
 
15
15
  import {useCallback, useRef, useState, type RefCallback} from 'react';
16
16
  import {observeResize} from '../utils/sharedResizeObserver';
17
+ import {observeContent} from '../utils/sharedMutationObserver';
17
18
 
18
19
  export interface UseTruncationOptions {
19
20
  /**
@@ -43,10 +44,12 @@ export interface UseTruncationReturn {
43
44
  /**
44
45
  * Hook for detecting text overflow/truncation.
45
46
  *
46
- * Uses a shared ResizeObserver singleton (via observeResize)
47
- * for efficient detection when content or container changes. A single
48
- * ResizeObserver instance is shared across all mounted useTruncation hooks,
49
- * so even hundreds of table cells only create one observer.
47
+ * Uses a shared ResizeObserver singleton (via observeResize) for size
48
+ * changes and a shared MutationObserver singleton (via observeContent) for
49
+ * content changes, so even hundreds of table cells create one observer of
50
+ * each kind. Observing content matters because an element whose text changes
51
+ * without resizing (a fixed-width status line) must still be re-measured for
52
+ * the tooltip text and the truncated state to follow the current content.
50
53
  *
51
54
  * - Single-line: compares scrollWidth > offsetWidth
52
55
  * - Multi-line: uses Range.getBoundingClientRect() to measure actual content
@@ -71,6 +74,7 @@ export function useTruncation(
71
74
  const [fullText, setFullText] = useState('');
72
75
  const elementRef = useRef<HTMLElement | null>(null);
73
76
  const unobserveRef = useRef<(() => void) | null>(null);
77
+ const unobserveContentRef = useRef<(() => void) | null>(null);
74
78
 
75
79
  const checkTruncation = useCallback(
76
80
  (element: HTMLElement) => {
@@ -113,6 +117,8 @@ export function useTruncation(
113
117
  // by element leaves any other observer of the same node untouched.
114
118
  unobserveRef.current?.();
115
119
  unobserveRef.current = null;
120
+ unobserveContentRef.current?.();
121
+ unobserveContentRef.current = null;
116
122
 
117
123
  elementRef.current = element;
118
124
 
@@ -127,6 +133,11 @@ export function useTruncation(
127
133
  } else {
128
134
  checkTruncation(element);
129
135
  }
136
+ // New text in a box that keeps its size fires no resize, so the
137
+ // captured text and truncated state would otherwise stay stale.
138
+ unobserveContentRef.current = observeContent(element, () => {
139
+ checkTruncation(element);
140
+ });
130
141
  } else {
131
142
  setIsTruncated(false);
132
143
  setFullText('');
@@ -480,6 +480,22 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
480
480
  '@astryx.transferListSelector.apply': 'Apply',
481
481
  '@astryx.transferListSelector.cancel': 'Cancel',
482
482
  '@astryx.transferListSelector.triggerLabel': '{count, number} selected',
483
+ '@astryx.emojiPicker.label': 'Emoji',
484
+ '@astryx.emojiPicker.searchLabel': 'Search emoji',
485
+ '@astryx.emojiPicker.frequentlyUsed': 'Frequently used',
486
+ '@astryx.emojiPicker.searchResults': 'Search results',
487
+ '@astryx.emojiPicker.noResults': 'No emoji match “{query}”',
488
+ '@astryx.emojiPicker.loading': 'Loading emoji',
489
+ '@astryx.emojiPicker.loadFailed': "More emoji couldn't be loaded.",
490
+ '@astryx.emojiPicker.groupSmileysEmotion': 'Smileys & emotion',
491
+ '@astryx.emojiPicker.groupPeopleBody': 'People & body',
492
+ '@astryx.emojiPicker.groupAnimalsNature': 'Animals & nature',
493
+ '@astryx.emojiPicker.groupFoodDrink': 'Food & drink',
494
+ '@astryx.emojiPicker.groupTravelPlaces': 'Travel & places',
495
+ '@astryx.emojiPicker.groupActivities': 'Activities',
496
+ '@astryx.emojiPicker.groupObjects': 'Objects',
497
+ '@astryx.emojiPicker.groupSymbols': 'Symbols',
498
+ '@astryx.emojiPicker.groupFlags': 'Flags',
483
499
  };
484
500
 
485
501
  export default runtimeCatalog;
@@ -489,6 +489,22 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
489
489
  '@astryx.transferListSelector.apply': '⟦Àþþłý⟧',
490
490
  '@astryx.transferListSelector.cancel': '⟦Çàñçéł⟧',
491
491
  '@astryx.transferListSelector.triggerLabel': '⟦{count, number} šéłéçţéð⟧',
492
+ '@astryx.emojiPicker.label': '⟦Éɱóĵí⟧',
493
+ '@astryx.emojiPicker.searchLabel': '⟦Šéàřçĥ éɱóĵí⟧',
494
+ '@astryx.emojiPicker.frequentlyUsed': '⟦Ƒřéɋúéñţłý úšéð⟧',
495
+ '@astryx.emojiPicker.searchResults': '⟦Šéàřçĥ řéšúłţš⟧',
496
+ '@astryx.emojiPicker.noResults': '⟦Ñó éɱóĵí ɱàţçĥ “{query}”⟧',
497
+ '@astryx.emojiPicker.loading': '⟦Łóàðíñĝ éɱóĵí⟧',
498
+ '@astryx.emojiPicker.loadFailed': "⟦Ṁóřé éɱóĵí çóúłðñ'ţ ƀé łóàðéð.⟧",
499
+ '@astryx.emojiPicker.groupSmileysEmotion': '⟦Šɱíłéýš & éɱóţíóñ⟧',
500
+ '@astryx.emojiPicker.groupPeopleBody': '⟦Þéóþłé & ƀóðý⟧',
501
+ '@astryx.emojiPicker.groupAnimalsNature': '⟦Àñíɱàłš & ñàţúřé⟧',
502
+ '@astryx.emojiPicker.groupFoodDrink': '⟦Ƒóóð & ðříñķ⟧',
503
+ '@astryx.emojiPicker.groupTravelPlaces': '⟦Ţřàṽéł & þłàçéš⟧',
504
+ '@astryx.emojiPicker.groupActivities': '⟦Àçţíṽíţíéš⟧',
505
+ '@astryx.emojiPicker.groupObjects': '⟦Óƀĵéçţš⟧',
506
+ '@astryx.emojiPicker.groupSymbols': '⟦Šýɱƀółš⟧',
507
+ '@astryx.emojiPicker.groupFlags': '⟦Ƒłàĝš⟧',
492
508
  };
493
509
 
494
510
  export default runtimeCatalog;
@@ -0,0 +1,53 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, it, expect, vi} from 'vitest';
4
+ import {observeContent} from './sharedMutationObserver';
5
+
6
+ const flush = async () => new Promise(resolve => setTimeout(resolve, 0));
7
+
8
+ describe('observeContent', () => {
9
+ it('calls a registration once per batch for changes anywhere inside its element', async () => {
10
+ const element = document.createElement('div');
11
+ element.innerHTML = '<span>first</span>';
12
+ const callback = vi.fn();
13
+ const unsubscribe = observeContent(element, callback);
14
+
15
+ element.querySelector('span')!.firstChild!.nodeValue = 'second';
16
+ element.append('third');
17
+ await flush();
18
+
19
+ expect(callback).toHaveBeenCalledTimes(1);
20
+ unsubscribe();
21
+ });
22
+
23
+ it('removes only its own registration', async () => {
24
+ const element = document.createElement('div');
25
+ const kept = vi.fn();
26
+ const removed = vi.fn();
27
+ const unsubscribeKept = observeContent(element, kept);
28
+ const unsubscribeRemoved = observeContent(element, removed);
29
+
30
+ unsubscribeRemoved();
31
+ element.textContent = 'changed';
32
+ await flush();
33
+
34
+ expect(kept).toHaveBeenCalledTimes(1);
35
+ expect(removed).not.toHaveBeenCalled();
36
+ unsubscribeKept();
37
+ });
38
+
39
+ it('ignores a released element', async () => {
40
+ const released = document.createElement('div');
41
+ const live = document.createElement('div');
42
+ const releasedCallback = vi.fn();
43
+ const unsubscribeReleased = observeContent(released, releasedCallback);
44
+ const unsubscribeLive = observeContent(live, vi.fn());
45
+
46
+ unsubscribeReleased();
47
+ released.textContent = 'changed';
48
+ await flush();
49
+
50
+ expect(releasedCallback).not.toHaveBeenCalled();
51
+ unsubscribeLive();
52
+ });
53
+ });
@@ -0,0 +1,78 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file sharedMutationObserver.ts
7
+ * @input MutationObserver API
8
+ * @output Exports observeContent for shared content-change observation
9
+ * @position Internal utility; consumed by useTruncation. The content-change
10
+ * counterpart of sharedResizeObserver: one observer for every element, so a
11
+ * repeated cell or row path does not create one observer per instance.
12
+ */
13
+
14
+ type ContentCallback = () => void;
15
+
16
+ const OPTIONS: MutationObserverInit = {
17
+ characterData: true,
18
+ childList: true,
19
+ subtree: true,
20
+ };
21
+
22
+ let observer: MutationObserver | null = null;
23
+ const callbacks = new Map<Node, Set<ContentCallback>>();
24
+
25
+ function dispatch(records: MutationRecord[]): void {
26
+ // One call per callback per batch, however many records it received. A
27
+ // record's target can be any descendant, so walk up to the observed roots.
28
+ const pending = new Set<ContentCallback>();
29
+ for (const record of records) {
30
+ for (let node: Node | null = record.target; node; node = node.parentNode) {
31
+ callbacks.get(node)?.forEach(callback => pending.add(callback));
32
+ }
33
+ }
34
+ pending.forEach(callback => callback());
35
+ }
36
+
37
+ /**
38
+ * Call `callback` when the element's text or children change (including
39
+ * descendants). Returns an unsubscribe that removes only this registration.
40
+ *
41
+ * MutationObserver has no per-target unobserve, so a released element stays
42
+ * registered with the shared observer until no element remains; its records
43
+ * match no callback and are ignored. The observer disconnects and is released
44
+ * when the last registration goes.
45
+ */
46
+ export function observeContent(
47
+ element: Element,
48
+ callback: ContentCallback,
49
+ ): () => void {
50
+ if (typeof MutationObserver === 'undefined') {
51
+ return () => {};
52
+ }
53
+ observer ??= new MutationObserver(dispatch);
54
+ const existing = callbacks.get(element);
55
+ if (existing) {
56
+ existing.add(callback);
57
+ } else {
58
+ callbacks.set(element, new Set([callback]));
59
+ observer.observe(element, OPTIONS);
60
+ }
61
+
62
+ let isSubscribed = true;
63
+ return () => {
64
+ if (!isSubscribed) {
65
+ return;
66
+ }
67
+ isSubscribed = false;
68
+ const targetCallbacks = callbacks.get(element);
69
+ targetCallbacks?.delete(callback);
70
+ if (targetCallbacks?.size === 0) {
71
+ callbacks.delete(element);
72
+ }
73
+ if (callbacks.size === 0 && observer) {
74
+ observer.disconnect();
75
+ observer = null;
76
+ }
77
+ };
78
+ }