@symbiote-native/angular 0.8.0 → 1.0.0

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 (121) hide show
  1. package/babel-lower-host-primitives.cjs +636 -0
  2. package/babel-register-composed.cjs +21 -3
  3. package/build/angular/anchor-host-registry.js +3 -1
  4. package/build/angular/anchor-host-registry.js.map +1 -1
  5. package/build/angular/components/button.d.ts +3 -0
  6. package/build/angular/components/button.js +10 -1
  7. package/build/angular/components/button.js.map +1 -1
  8. package/build/angular/components/image/index.android.js +0 -8
  9. package/build/angular/components/image/index.android.js.map +1 -1
  10. package/build/angular/components/image/index.ios.js +0 -8
  11. package/build/angular/components/image/index.ios.js.map +1 -1
  12. package/build/angular/components/image/shared.d.ts +4 -4
  13. package/build/angular/components/image/shared.js +33 -27
  14. package/build/angular/components/image/shared.js.map +1 -1
  15. package/build/angular/components/image-background.d.ts +3 -0
  16. package/build/angular/components/image-background.js +10 -1
  17. package/build/angular/components/image-background.js.map +1 -1
  18. package/build/angular/components/input-accessory-view/index.d.ts +2 -1
  19. package/build/angular/components/input-accessory-view/index.js +11 -14
  20. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  21. package/build/angular/components/keyboard-avoiding-view/index.d.ts +2 -1
  22. package/build/angular/components/keyboard-avoiding-view/index.js +17 -10
  23. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  24. package/build/angular/components/modal/index.d.ts +1 -0
  25. package/build/angular/components/modal/index.js +14 -8
  26. package/build/angular/components/modal/index.js.map +1 -1
  27. package/build/angular/components/pressable/index.d.ts +9 -4
  28. package/build/angular/components/pressable/index.js +55 -14
  29. package/build/angular/components/pressable/index.js.map +1 -1
  30. package/build/angular/components/refresh-control/index.d.ts +2 -1
  31. package/build/angular/components/refresh-control/index.js +10 -8
  32. package/build/angular/components/refresh-control/index.js.map +1 -1
  33. package/build/angular/components/safe-area-view/index.d.ts +3 -1
  34. package/build/angular/components/safe-area-view/index.js +19 -19
  35. package/build/angular/components/safe-area-view/index.js.map +1 -1
  36. package/build/angular/components/scroll-view/shared.d.ts +2 -0
  37. package/build/angular/components/scroll-view/shared.js +20 -4
  38. package/build/angular/components/scroll-view/shared.js.map +1 -1
  39. package/build/angular/components/section-list/index.d.ts +3 -0
  40. package/build/angular/components/section-list/index.js +10 -1
  41. package/build/angular/components/section-list/index.js.map +1 -1
  42. package/build/angular/components/switch/index.android.js +6 -6
  43. package/build/angular/components/switch/index.android.js.map +1 -1
  44. package/build/angular/components/switch/index.ios.js +6 -6
  45. package/build/angular/components/switch/index.ios.js.map +1 -1
  46. package/build/angular/components/text-input.d.ts +7 -1
  47. package/build/angular/components/text-input.js +54 -29
  48. package/build/angular/components/text-input.js.map +1 -1
  49. package/build/angular/components/touchable/index.d.ts +7 -0
  50. package/build/angular/components/touchable/index.js +34 -3
  51. package/build/angular/components/touchable/index.js.map +1 -1
  52. package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
  53. package/build/angular/components/touchable-native-feedback/index.js +17 -15
  54. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  55. package/build/angular/components/virtualized-list/index.d.ts +3 -0
  56. package/build/angular/components/virtualized-list/index.js +10 -1
  57. package/build/angular/components/virtualized-list/index.js.map +1 -1
  58. package/build/angular/components/virtualized-section-list/index.d.ts +3 -0
  59. package/build/angular/components/virtualized-section-list/index.js +10 -1
  60. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  61. package/build/angular/components.d.ts +1 -1
  62. package/build/angular/components.js +1 -1
  63. package/build/angular/components.js.map +1 -1
  64. package/build/angular/create-portal/index.d.ts +11 -2
  65. package/build/angular/create-portal/index.js +37 -11
  66. package/build/angular/create-portal/index.js.map +1 -1
  67. package/build/angular/gate-demand.d.ts +37 -0
  68. package/build/angular/gate-demand.js +84 -0
  69. package/build/angular/gate-demand.js.map +1 -0
  70. package/build/angular/index.d.ts +1 -0
  71. package/build/angular/index.js +1 -0
  72. package/build/angular/index.js.map +1 -1
  73. package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
  74. package/build/angular/modules/animated/create-animated-component.js +14 -9
  75. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  76. package/build/angular/primitives/index.d.ts +13 -1
  77. package/build/angular/primitives/index.js +43 -1
  78. package/build/angular/primitives/index.js.map +1 -1
  79. package/build/angular/register.d.ts +1 -0
  80. package/build/angular/register.js +35 -0
  81. package/build/angular/register.js.map +1 -0
  82. package/build/angular/render/index.js +3 -1
  83. package/build/angular/render/index.js.map +1 -1
  84. package/build/angular/renderer/index.js +120 -19
  85. package/build/angular/renderer/index.js.map +1 -1
  86. package/build/angular/state-style.d.ts +9 -0
  87. package/build/angular/state-style.js +39 -0
  88. package/build/angular/state-style.js.map +1 -0
  89. package/metro-transformer.cjs +69 -0
  90. package/package.json +27 -8
  91. package/src/anchor-host-registry.ts +3 -1
  92. package/src/components/button.ts +17 -0
  93. package/src/components/image/index.android.ts +0 -4
  94. package/src/components/image/index.ios.ts +0 -4
  95. package/src/components/image/shared.ts +52 -26
  96. package/src/components/image-background.ts +18 -0
  97. package/src/components/input-accessory-view/index.ts +14 -8
  98. package/src/components/keyboard-avoiding-view/index.ts +21 -7
  99. package/src/components/modal/index.ts +17 -4
  100. package/src/components/pressable/index.ts +70 -10
  101. package/src/components/refresh-control/index.ts +14 -5
  102. package/src/components/safe-area-view/index.ts +28 -12
  103. package/src/components/scroll-view/shared.ts +31 -4
  104. package/src/components/section-list/index.ts +18 -0
  105. package/src/components/switch/index.android.ts +4 -4
  106. package/src/components/switch/index.ios.ts +4 -4
  107. package/src/components/text-input.ts +73 -17
  108. package/src/components/touchable/index.ts +43 -0
  109. package/src/components/touchable-native-feedback/index.ts +18 -8
  110. package/src/components/virtualized-list/index.ts +18 -0
  111. package/src/components/virtualized-section-list/index.ts +18 -0
  112. package/src/components.ts +2 -0
  113. package/src/create-portal/index.ts +39 -9
  114. package/src/gate-demand.ts +109 -0
  115. package/src/index.ts +2 -0
  116. package/src/modules/animated/create-animated-component.ts +23 -4
  117. package/src/primitives/index.ts +28 -1
  118. package/src/register.ts +43 -0
  119. package/src/render/index.ts +3 -1
  120. package/src/renderer/index.ts +144 -34
  121. package/src/state-style.ts +39 -0
@@ -1,4 +1,9 @@
1
1
  import { EventEmitter, computed, signal } from '@angular/core';
2
+ import {
3
+ gateWanted,
4
+ injectGateDemand,
5
+ type IGatedAccessibilityEvent,
6
+ } from '../../gate-demand';
2
7
  import {
3
8
  imageStatics,
4
9
  renderImage,
@@ -162,6 +167,14 @@ function asStyle(value: unknown): IStyleProp<IViewStyle> | undefined {
162
167
  return typeof value === 'object' && value !== null ? value : undefined;
163
168
  }
164
169
 
170
+ // Narrows a value out of the untyped animated prop bag. A runtime guard rather than a cast: the
171
+ // bag is built at runtime from merged animated values, so nothing upstream has proven the shape.
172
+ export function isImageEventCallback(
173
+ value: unknown,
174
+ ): value is (event: ISymbioteEvent) => void {
175
+ return typeof value === 'function';
176
+ }
177
+
165
178
  export function resolveImageProps(
166
179
  input: Record<string, unknown>,
167
180
  ): Record<string, unknown> {
@@ -382,28 +395,32 @@ export abstract class ImageBase {
382
395
  readonly progress = new EventEmitter<ISymbioteEvent>();
383
396
  readonly partialLoad = new EventEmitter<ISymbioteEvent>();
384
397
 
385
- handleAccessibilityAction(event: Event): void {
386
- if (!isSymbioteEvent(event)) return;
387
- this.onAccessibilityAction?.(event);
388
- this.accessibilityAction.emit(event);
389
- }
390
-
391
- handleAccessibilityTap(event: Event): void {
392
- if (!isSymbioteEvent(event)) return;
393
- this.onAccessibilityTap?.(event);
394
- this.accessibilityTap.emit(event);
395
- }
396
-
397
- handleMagicTap(event: Event): void {
398
- if (!isSymbioteEvent(event)) return;
399
- this.onMagicTap?.(event);
400
- this.magicTap.emit(event);
401
- }
402
-
403
- handleAccessibilityEscape(event: Event): void {
404
- if (!isSymbioteEvent(event)) return;
405
- this.onAccessibilityEscape?.(event);
406
- this.accessibilityEscape.emit(event);
398
+ // Null unless an adapter wrapper renders this Image in its own template — ImageBackground does.
399
+ // An app's own `<Image>` gets null and answers from its own `.observed`.
400
+ private readonly gateDemand = injectGateDemand();
401
+
402
+ // The four accessibility events are boolean-GATED Fabric events
403
+ // (`.claude/rules/fabric-boolean-event-gates.md`): native fires them only when the committed
404
+ // payload carries a FUNCTION at that key. A template `(accessibilityAction)="..."` binding on the
405
+ // intrinsic host lights the gate unconditionally; this reaches the app two ways — a plain
406
+ // `[onAccessibilityAction]` @Input callback, or the `accessibilityAction` @Output — and the gate
407
+ // must light only when at least one of the two is actually wired. Returned `undefined` means
408
+ // `setEventListener` never sees a function, so the gate stays dark.
409
+ protected gatedAccessibilityHandler(
410
+ name: IGatedAccessibilityEvent,
411
+ callback: ((event: ISymbioteEvent) => void) | undefined,
412
+ ): ((event: Event) => void) | undefined {
413
+ const emitter = this[name];
414
+ // A WRAPPER's binding is not a subscriber, and `.observed` cannot tell the two apart.
415
+ // ImageBackground renders this component and binds all four, so without the demand every
416
+ // Image inside one lit its gates. See `gate-demand.ts`.
417
+ if (!gateWanted(this.gateDemand, name, emitter) && callback === undefined)
418
+ return undefined;
419
+ return event => {
420
+ if (!isSymbioteEvent(event)) return;
421
+ callback?.(event);
422
+ emitter.emit(event);
423
+ };
407
424
  }
408
425
 
409
426
  handleLoadStart(event: Event): void {
@@ -499,10 +516,19 @@ export abstract class ImageBase {
499
516
  ariaValueMin: this.ariaValueMin,
500
517
  ariaValueNow: this.ariaValueNow,
501
518
  ariaValueText: this.ariaValueText,
502
- onAccessibilityAction: this.onAccessibilityAction,
503
- onAccessibilityTap: this.onAccessibilityTap,
504
- onMagicTap: this.onMagicTap,
505
- onAccessibilityEscape: this.onAccessibilityEscape,
519
+ onAccessibilityAction: this.gatedAccessibilityHandler(
520
+ 'accessibilityAction',
521
+ this.onAccessibilityAction,
522
+ ),
523
+ onAccessibilityTap: this.gatedAccessibilityHandler(
524
+ 'accessibilityTap',
525
+ this.onAccessibilityTap,
526
+ ),
527
+ onMagicTap: this.gatedAccessibilityHandler('magicTap', this.onMagicTap),
528
+ onAccessibilityEscape: this.gatedAccessibilityHandler(
529
+ 'accessibilityEscape',
530
+ this.onAccessibilityEscape,
531
+ ),
506
532
  onLoadStart: this.onLoadStart,
507
533
  onLoad: this.onLoad,
508
534
  onLoadEnd: this.onLoadEnd,
@@ -42,6 +42,13 @@ import {
42
42
  SymbioteStyleInputDirective,
43
43
  ViewHost,
44
44
  } from '../primitives';
45
+ import {
46
+ gateWanted,
47
+ injectGateDemandAbove,
48
+ provideGateDemand,
49
+ type IGateDemand,
50
+ type IGatedAccessibilityEvent,
51
+ } from '../gate-demand';
45
52
 
46
53
  // Mirrors React's IImageBackgroundProps minus children (Angular takes children via <ng-content>):
47
54
  // every forwarding Image prop flows onto the inner image; `style` is the WRAPPER View style and
@@ -75,6 +82,7 @@ export type IAngularImageBackgroundInputs = Omit<
75
82
  @Component({
76
83
  selector: 'ImageBackground',
77
84
  standalone: true,
85
+ viewProviders: [provideGateDemand(() => ImageBackground)],
78
86
  hostDirectives: [
79
87
  { directive: SymbioteStyleInputDirective, inputs: ['style'] },
80
88
  ],
@@ -150,6 +158,16 @@ export class ImageBackground implements IAngularImageBackgroundInputs {
150
158
  @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
151
159
  @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
152
160
  @Output() readonly accessibilityEscape = new EventEmitter<ISymbioteEvent>();
161
+
162
+ // This wrapper binds the four gated accessibility events on the component it renders, which
163
+ // Angular forces to be unconditional and which would light that component's gates on every
164
+ // instance. It answers for them instead — see `gate-demand.ts`.
165
+ private readonly gateDemandAbove = injectGateDemandAbove();
166
+
167
+ wantsGate(name: IGatedAccessibilityEvent): boolean {
168
+ return gateWanted(this.gateDemandAbove, name, this[name]);
169
+ }
170
+
153
171
  @Output() readonly loadStart = new EventEmitter<ISymbioteEvent>();
154
172
  @Output() readonly load = new EventEmitter<ISymbioteEvent>();
155
173
  @Output() readonly loadEnd = new EventEmitter<ISymbioteEvent>();
@@ -72,13 +72,7 @@ export type IAngularInputAccessoryViewInputs = Omit<
72
72
  imports: [InputAccessoryViewHost, SymbioteHostPropsDirective],
73
73
  changeDetection: ChangeDetectionStrategy.OnPush,
74
74
  template: `
75
- <symbiote-input-accessory-view
76
- [symbioteHostProps]="hostProps()"
77
- (accessibilityAction)="emit(accessibilityAction, $event)"
78
- (accessibilityTap)="emit(accessibilityTap, $event)"
79
- (magicTap)="emit(magicTap, $event)"
80
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
81
- >
75
+ <symbiote-input-accessory-view [symbioteHostProps]="hostProps()">
82
76
  <ng-content></ng-content>
83
77
  </symbiote-input-accessory-view>
84
78
  `,
@@ -136,10 +130,18 @@ export class InputAccessoryView
136
130
  private readonly elementRef = inject(ElementRef);
137
131
 
138
132
  // Forward an engine event to the matching @Output(), narrowing the template's untyped $event.
139
- emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
133
+ private emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
140
134
  if (isSymbioteEvent(event)) emitter.emit(event);
141
135
  }
142
136
 
137
+ // The four accessibility events are boolean-GATED Fabric events
138
+ // (`.claude/rules/fabric-boolean-event-gates.md`). `.observed`-gated, mirroring Pressable's.
139
+ private eventEmitterHandler(
140
+ emitter: EventEmitter<ISymbioteEvent>,
141
+ ): ((event: unknown) => void) | undefined {
142
+ return emitter.observed ? event => this.emit(emitter, event) : undefined;
143
+ }
144
+
143
145
  // renderInputAccessoryView owns the host-node assembly (shared with React/Vue); the adapter reads
144
146
  // the resolved host props off the Descriptor it returns.
145
147
  private get descriptor() {
@@ -190,6 +192,10 @@ export class InputAccessoryView
190
192
  return {
191
193
  ...descriptorProps,
192
194
  style: [anchorHostStyle(this.elementRef), descriptorProps.style],
195
+ onAccessibilityAction: this.eventEmitterHandler(this.accessibilityAction),
196
+ onAccessibilityTap: this.eventEmitterHandler(this.accessibilityTap),
197
+ onMagicTap: this.eventEmitterHandler(this.magicTap),
198
+ onAccessibilityEscape: this.eventEmitterHandler(this.accessibilityEscape),
193
199
  };
194
200
  });
195
201
 
@@ -96,13 +96,15 @@ export type IAngularKeyboardAvoidingViewInputs = Omit<
96
96
  // The wrapper carries a11y + onLayout; 'position' ('nested') pushes the children in an inner
97
97
  // View by `bottom: inset`, the wrapper modes adjust the single wrapper directly. Only one @if
98
98
  // branch is instantiated, so each branch's <ng-content> projects the children unambiguously.
99
+ // `layout` stays a plain, unconditional template binding: unlike the four accessibility events
100
+ // below, this component READS its own onLayout internally (handleLayout measures the frame that
101
+ // feeds the next keyboard event's inset math — see keyboard-avoiding-view-rn-contract.md), so the
102
+ // gate must stay lit regardless of whether the app subscribes to the `layout` @Output(). Gating it
103
+ // on `.observed` would silently break the inset fixpoint correction the moment an app didn't
104
+ // listen to `layout`.
99
105
  template: `
100
106
  <symbiote-view
101
107
  [symbioteHostProps]="hostProps"
102
- (accessibilityAction)="emit(accessibilityAction, $event)"
103
- (accessibilityTap)="emit(accessibilityTap, $event)"
104
- (magicTap)="emit(magicTap, $event)"
105
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
106
108
  (layout)="handleLayout($event)"
107
109
  >
108
110
  @if (isNested) {
@@ -296,16 +298,28 @@ export class KeyboardAvoidingView
296
298
  nativeID: this.nativeID,
297
299
  accessible: this.accessible,
298
300
  ...this.folded,
301
+ onAccessibilityAction: this.eventEmitterHandler(this.accessibilityAction),
302
+ onAccessibilityTap: this.eventEmitterHandler(this.accessibilityTap),
303
+ onMagicTap: this.eventEmitterHandler(this.magicTap),
304
+ onAccessibilityEscape: this.eventEmitterHandler(this.accessibilityEscape),
299
305
  };
300
306
  }
301
307
 
302
308
  // Forward an engine event to the matching @Output(), narrowing the template's untyped $event
303
- // first. The accessibility* Outputs ride the engine's structural event channel (Angular blocks
304
- // [onX] property bindings; events flow through (event) only).
305
- emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
309
+ // first.
310
+ private emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
306
311
  if (isSymbioteEvent(event)) emitter.emit(event);
307
312
  }
308
313
 
314
+ // The four accessibility events are boolean-GATED Fabric events
315
+ // (`.claude/rules/fabric-boolean-event-gates.md`). `.observed`-gated, unlike `layout` above which
316
+ // this component needs unconditionally for its own internal frame measurement.
317
+ private eventEmitterHandler(
318
+ emitter: EventEmitter<ISymbioteEvent>,
319
+ ): ((event: unknown) => void) | undefined {
320
+ return emitter.observed ? event => this.emit(emitter, event) : undefined;
321
+ }
322
+
309
323
  // Fold the web aria-*/role aliases into the canonical accessibility* props once per render, so the
310
324
  // host node never sees an aria-* key (native ignores them) — the shared transform every adapter runs.
311
325
  get folded(): Partial<IAngularKeyboardAvoidingViewProps> {
@@ -115,10 +115,6 @@ export type IAngularModalInputs = Omit<
115
115
  (dismiss)="dismiss.emit()"
116
116
  (requestClose)="requestClose.emit()"
117
117
  (orientationChange)="emit(orientationChange, $event)"
118
- (accessibilityAction)="emit(accessibilityAction, $event)"
119
- (accessibilityTap)="emit(accessibilityTap, $event)"
120
- (magicTap)="emit(magicTap, $event)"
121
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
122
118
  >
123
119
  <symbiote-view [style]="containerStyle" [collapsable]="false">
124
120
  <ng-content></ng-content>
@@ -279,6 +275,10 @@ export class Modal implements IAngularModalInputs, OnInit, OnChanges, DoCheck {
279
275
  return {
280
276
  ...descriptorProps,
281
277
  style: [anchorHostStyle(this.elementRef), descriptorProps.style],
278
+ onAccessibilityAction: this.eventEmitterHandler(this.accessibilityAction),
279
+ onAccessibilityTap: this.eventEmitterHandler(this.accessibilityTap),
280
+ onMagicTap: this.eventEmitterHandler(this.magicTap),
281
+ onAccessibilityEscape: this.eventEmitterHandler(this.accessibilityEscape),
282
282
  };
283
283
  });
284
284
 
@@ -287,10 +287,23 @@ export class Modal implements IAngularModalInputs, OnInit, OnChanges, DoCheck {
287
287
  return typeof container === 'string' ? undefined : container.props.style;
288
288
  }
289
289
 
290
+ // NOT private: `(orientationChange)="emit(...)"` in the template above calls it, and a template
291
+ // can only reach public members. `tsc` does not typecheck Angular templates, so `private` here
292
+ // compiles clean and fails only under `ngc --compilationMode partial`.
290
293
  emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
291
294
  if (isSymbioteEvent(event)) emitter.emit(event);
292
295
  }
293
296
 
297
+ // The four accessibility events are boolean-GATED Fabric events
298
+ // (`.claude/rules/fabric-boolean-event-gates.md`) — `show`/`dismiss`/`requestClose`/
299
+ // `orientationChange` are NOT (Modal's own MODAL_EVENTS), so those stay unconditional template
300
+ // bindings above. `.observed`-gated, mirroring Pressable's.
301
+ private eventEmitterHandler(
302
+ emitter: EventEmitter<ISymbioteEvent>,
303
+ ): ((event: unknown) => void) | undefined {
304
+ return emitter.observed ? event => this.emit(emitter, event) : undefined;
305
+ }
306
+
294
307
  // Typed as the a11y intersection WITH the string index (the bag renderModal spreads into the
295
308
  // host props), so resolveAccessibilityProps's result stays assignable to passthrough.
296
309
  private accessibilityInputs(): IAccessibilityProps &
@@ -13,11 +13,14 @@ import {
13
13
  ViewChild,
14
14
  type DoCheck,
15
15
  type OnChanges,
16
+ type OnDestroy,
16
17
  } from '@angular/core';
17
18
  import {
18
19
  createPressHandlers,
19
20
  createPressRuntime,
21
+ disposePressRuntime,
20
22
  DEFAULT_DELAY_LONG_PRESS_MS,
23
+ DEFAULT_MIN_PRESS_DURATION_MS,
21
24
  isTerminationAllowed,
22
25
  resolveAccessibilityProps,
23
26
  resolveDisabledAccessibilityState,
@@ -50,6 +53,11 @@ import {
50
53
  SymbioteStyleInputDirective,
51
54
  ViewHost,
52
55
  } from '../../primitives';
56
+ import {
57
+ gateWanted,
58
+ injectGateDemand,
59
+ type IGatedAccessibilityEvent,
60
+ } from '../../gate-demand';
53
61
 
54
62
  export type {
55
63
  IPressState,
@@ -123,7 +131,18 @@ function asSymbioteEvent(event: unknown): ISymbioteEvent | undefined {
123
131
  }
124
132
 
125
133
  @Component({
126
- selector: 'Pressable, symbiote-pressable',
134
+ // Single-name selector, matching TextInput's own ('TextInput', text-input.ts) rather than
135
+ // View/Text's dual one. The alternate `symbiote-pressable` spelling was vestigial — nothing in
136
+ // this component's own template ever renders that tag (it renders `symbiote-view`, below), and
137
+ // no app or example code invokes the composed component by that literal tag (verified by grep,
138
+ // 2026-08-31). Its only effect was to force `symbiote-pressable` into
139
+ // `ANCHOR_HOST_COMPONENTS` unconditionally, which collided with the tag's OTHER, load-bearing
140
+ // meaning — the host-behavior registry key (`PRESSABLE_TAG`,
141
+ // core/components/src/behaviors/pressable.ts) a lowered `<Pressable>` must carry to get the
142
+ // press machine. That collision is exactly why `DIAGNOSTIC_LOWERED_PRESSABLE_TAG` existed
143
+ // (renderer/index.ts) — dropping the vestigial spelling here removes the collision at its
144
+ // source, so the escape hatch is retired rather than worked around.
145
+ selector: 'Pressable',
127
146
  standalone: true,
128
147
  hostDirectives: [
129
148
  { directive: SymbioteStyleInputDirective, inputs: ['style'] },
@@ -134,10 +153,6 @@ function asSymbioteEvent(event: unknown): ISymbioteEvent | undefined {
134
153
  <symbiote-view
135
154
  #host
136
155
  [symbioteHostProps]="hostProps()"
137
- (accessibilityAction)="emit(accessibilityAction, $event)"
138
- (accessibilityTap)="emit(accessibilityTap, $event)"
139
- (magicTap)="emit(magicTap, $event)"
140
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
141
156
  (press)="handlePress($event)"
142
157
  (pressIn)="handlePressIn($event)"
143
158
  (pressOut)="handlePressOut($event)"
@@ -150,7 +165,9 @@ function asSymbioteEvent(event: unknown): ISymbioteEvent | undefined {
150
165
  `,
151
166
  changeDetection: ChangeDetectionStrategy.OnPush,
152
167
  })
153
- export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
168
+ export class Pressable
169
+ implements IAngularPressableInputs, OnChanges, OnDestroy, DoCheck
170
+ {
154
171
  // The press/hover lifecycle as real Angular events: `(press)="onTap($event)"`, not
155
172
  // `[onPress]="onTap"`. createPressHandlers still wants plain IPressHandler callbacks, so
156
173
  // emitterHandler() below adapts each EventEmitter into one - only while something is actually
@@ -163,9 +180,15 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
163
180
  @Output() readonly longPress = new EventEmitter<ISymbioteEvent>();
164
181
  @Output() readonly hoverIn = new EventEmitter<ISymbioteEvent>();
165
182
  @Output() readonly hoverOut = new EventEmitter<ISymbioteEvent>();
166
- // The four accessibility callbacks as real Angular events too, same tradeoff as press/hover
167
- // above: `(accessibilityAction)="emit(accessibilityAction, $event)"`, not
168
- // `[onAccessibilityAction]="onAccessibilityAction"`.
183
+ // The four accessibility callbacks as real Angular events, app-facing. UNLIKE press/hover above,
184
+ // these are NOT bound in the template at all — `accessibilityAction`/`accessibilityTap`/
185
+ // `magicTap`/`accessibilityEscape` are `onX`-gated Fabric events
186
+ // (`.claude/rules/fabric-boolean-event-gates.md`): the engine only emits them when the committed
187
+ // payload carries a FUNCTION at that key, so a `(accessibilityAction)="..."` template binding —
188
+ // always present once written — would light the gate on every instance regardless of whether the
189
+ // app ever subscribes. Routed through `hostProps()` instead (`accessibilityEmitterHandler`
190
+ // below), the same `.observed`-conditional shape `emitterHandler` already uses for press/hover,
191
+ // so the gate lights only when something is actually listening.
169
192
  @Output() readonly accessibilityAction = new EventEmitter<ISymbioteEvent>();
170
193
  @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
171
194
  @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
@@ -176,6 +199,8 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
176
199
  @Input() hitSlop?: IRectOffset;
177
200
  @Input() pressRetentionOffset?: IRectOffset;
178
201
  @Input() unstable_pressDelay?: number;
202
+ /** @internal Touchable* mirrors RN's Pressability minPressDuration: 0 override. */
203
+ @Input() __minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS;
179
204
  @Input() android_ripple?: IPressableAndroidRippleConfig;
180
205
  @Input() android_disableSound?: boolean;
181
206
  @Input() delayHoverIn?: number;
@@ -255,9 +280,14 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
255
280
  const id = setTimeout(callback, ms);
256
281
  return () => clearTimeout(id);
257
282
  },
283
+ now: Date.now,
258
284
  };
259
285
 
260
286
  private readonly changeDetector = inject(ChangeDetectorRef);
287
+ // Null unless an adapter wrapper renders this Pressable in its own template. See
288
+ // `gate-demand.ts`; an app's own `<Pressable>` — including one projected INTO a wrapper — gets
289
+ // null and falls back to its own `.observed`.
290
+ private readonly gateDemand = injectGateDemand();
261
291
  // This component's OWN host - the non-painting anchor `class="..."` at the use site resolves
262
292
  // onto (see anchorHostStyle's doc comment) - NOT `hostElement` above, which targets the real
263
293
  // inner `symbiote-view` one level down.
@@ -289,6 +319,10 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
289
319
  this.anchorStyle.set(anchorHostStyle(this.elementRef));
290
320
  }
291
321
 
322
+ ngOnDestroy(): void {
323
+ disposePressRuntime(this.runtime);
324
+ }
325
+
292
326
  ngOnChanges(): void {
293
327
  // Tells the computed bag below that an @Input moved - plain class fields are not reactive on
294
328
  // their own. Must stay in ngOnChanges: that is the single moment Angular has finished writing
@@ -371,6 +405,14 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
371
405
  style: [this.anchorStyle(), this.resolvedStyle],
372
406
  accessible: this.accessible,
373
407
  ...this.foldedAccessibility,
408
+ onAccessibilityAction: this.accessibilityEmitterHandler(
409
+ 'accessibilityAction',
410
+ ),
411
+ onAccessibilityTap: this.accessibilityEmitterHandler('accessibilityTap'),
412
+ onMagicTap: this.accessibilityEmitterHandler('magicTap'),
413
+ onAccessibilityEscape: this.accessibilityEmitterHandler(
414
+ 'accessibilityEscape',
415
+ ),
374
416
  android_disableSound: this.android_disableSound,
375
417
  nativeBackgroundAndroid:
376
418
  this.resolvedRippleProps?.nativeBackgroundAndroid,
@@ -415,7 +457,7 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
415
457
  return isTerminationAllowed(this.cancelable);
416
458
  }
417
459
 
418
- emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
460
+ private emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
419
461
  const symbioteEvent = asSymbioteEvent(event);
420
462
  if (symbioteEvent !== undefined) emitter.emit(symbioteEvent);
421
463
  }
@@ -439,6 +481,23 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
439
481
  return emitter.observed ? event => emitter.emit(event) : undefined;
440
482
  }
441
483
 
484
+ // The accessibility twin of emitterHandler, feeding hostProps()'s flat bag instead of
485
+ // createPressHandlers' config — see the accessibilityAction @Output()'s own comment for why
486
+ // these four specifically cannot be plain template `(x)="..."` bindings. `undefined` here means
487
+ // `setEventListener` never sees a function, so the gated prop's flag never lights
488
+ // (`fabric-boolean-event-gates.md`, "The flag follows the VALUE, not the key").
489
+ private accessibilityEmitterHandler(
490
+ name: IGatedAccessibilityEvent,
491
+ ): ((event: unknown) => void) | undefined {
492
+ const emitter = this[name];
493
+ // A WRAPPER's binding is not a subscriber, and `.observed` cannot tell the two apart. When a
494
+ // demand is supplied from above it decides instead — see `gate-demand.ts` for why the channel
495
+ // is DI rather than an `@Input`.
496
+ return gateWanted(this.gateDemand, name, emitter)
497
+ ? event => this.emit(emitter, event)
498
+ : undefined;
499
+ }
500
+
442
501
  private get handlers() {
443
502
  this.noteHoverNoop();
444
503
  const config: IPressMachineConfig = {
@@ -449,6 +508,7 @@ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
449
508
  onLongPress: this.emitterHandler(this.longPress),
450
509
  delayLongPress: this.delayLongPress ?? DEFAULT_DELAY_LONG_PRESS_MS,
451
510
  unstable_pressDelay: this.unstable_pressDelay ?? 0,
511
+ minPressDuration: this.__minPressDuration,
452
512
  hitSlop: this.hitSlop,
453
513
  pressRetentionOffset: this.pressRetentionOffset,
454
514
  };
@@ -109,10 +109,6 @@ export type IAngularRefreshControlInputs = Omit<
109
109
  #host
110
110
  [symbioteHostProps]="hostProps()"
111
111
  (refresh)="handleRefresh()"
112
- (accessibilityAction)="emit(accessibilityAction, $event)"
113
- (accessibilityTap)="emit(accessibilityTap, $event)"
114
- (magicTap)="emit(magicTap, $event)"
115
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
116
112
  >
117
113
  <ng-content></ng-content>
118
114
  </symbiote-refresh-control>
@@ -252,10 +248,19 @@ export class RefreshControl
252
248
 
253
249
  // Forward an engine event to the matching @Output(), narrowing the template's untyped $event
254
250
  // first. The accessibility* events arrive on the engine's structural event channel.
255
- emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
251
+ private emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
256
252
  if (isSymbioteEvent(event)) emitter.emit(event);
257
253
  }
258
254
 
255
+ // The four accessibility events are boolean-GATED Fabric events
256
+ // (`.claude/rules/fabric-boolean-event-gates.md`): a template binding here lit the gate on every
257
+ // instance whether or not an app ever subscribed. `.observed`-gated, mirroring Pressable's.
258
+ private eventEmitterHandler(
259
+ emitter: EventEmitter<ISymbioteEvent>,
260
+ ): ((event: unknown) => void) | undefined {
261
+ return emitter.observed ? event => this.emit(emitter, event) : undefined;
262
+ }
263
+
259
264
  // The anchor's class-derived style is NOT an @Input: `class="..."`/`[ngClass]` at the use site
260
265
  // resolves through the renderer's addClass onto this component's anchor host (see
261
266
  // anchorHostStyle's doc comment), so it never shows up in SimpleChanges and ngOnChanges alone
@@ -294,6 +299,10 @@ export class RefreshControl
294
299
  nativeID: this.nativeID,
295
300
  accessible: this.accessible,
296
301
  ...this.folded,
302
+ onAccessibilityAction: this.eventEmitterHandler(this.accessibilityAction),
303
+ onAccessibilityTap: this.eventEmitterHandler(this.accessibilityTap),
304
+ onMagicTap: this.eventEmitterHandler(this.magicTap),
305
+ onAccessibilityEscape: this.eventEmitterHandler(this.accessibilityEscape),
297
306
  };
298
307
  });
299
308
 
@@ -45,6 +45,12 @@ import {
45
45
  export interface IAngularSafeAreaViewProps
46
46
  extends IAccessibilityProps, IAriaProps {
47
47
  style?: IStyleProp<IViewStyle>;
48
+ // `id` — accepted here and folded to `nativeID` by the shared plan, matching upstream, whose
49
+ // SafeAreaView takes the full ViewProps surface. Added 2026-09-01 with `ID_ALIAS` on the spec
50
+ // entry, deliberately as ONE change: the alias without this prop would make lowering fold a key
51
+ // no spelling of the component accepts, and this prop without the alias would send a raw `id` to
52
+ // Fabric, which declares no such key on any of these views.
53
+ id?: string;
48
54
  onLayout?: (event: ISymbioteEvent) => void;
49
55
  }
50
56
 
@@ -70,14 +76,7 @@ export type IAngularSafeAreaViewInputs = Omit<
70
76
  imports: [SafeAreaViewHost, SymbioteHostPropsDirective],
71
77
  changeDetection: ChangeDetectionStrategy.OnPush,
72
78
  template: `
73
- <symbiote-safe-area-view
74
- [symbioteHostProps]="hostProps()"
75
- (accessibilityAction)="emit(accessibilityAction, $event)"
76
- (accessibilityTap)="emit(accessibilityTap, $event)"
77
- (magicTap)="emit(magicTap, $event)"
78
- (accessibilityEscape)="emit(accessibilityEscape, $event)"
79
- (layout)="emit(layout, $event)"
80
- >
79
+ <symbiote-safe-area-view [symbioteHostProps]="hostProps()">
81
80
  <ng-content></ng-content>
82
81
  </symbiote-safe-area-view>
83
82
  `,
@@ -89,6 +88,11 @@ export class SafeAreaView
89
88
  // Real @Output()s, not `[onX]="…"` callbacks. Safe to name `layout` the same as the native
90
89
  // `layout` event fired inside this component's own template — the engine's bubble() treats
91
90
  // ANCHOR_HOST_COMPONENTS as transparent to listener lookup, so there is no double-fire.
91
+ // `layout` plus the four accessibility events are all boolean-GATED Fabric events
92
+ // (`.claude/rules/fabric-boolean-event-gates.md`): native fires them only when the committed
93
+ // payload carries a FUNCTION at that key, so a template binding here lit the gate on every
94
+ // instance whether or not an app ever subscribed. Moved into `hostProps()`'s flat bag below,
95
+ // `.observed`-gated — the shape `Pressable`'s `accessibilityEmitterHandler` already uses.
92
96
  @Output() readonly layout = new EventEmitter<ISymbioteEvent>();
93
97
  @Output() readonly accessibilityAction = new EventEmitter<ISymbioteEvent>();
94
98
  @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
@@ -137,13 +141,20 @@ export class SafeAreaView
137
141
  // the only ElementRef in the class.
138
142
  private readonly elementRef = inject(ElementRef);
139
143
 
140
- // Forward an engine event to the matching @Output(), narrowing the template's untyped
141
- // $event first. layout / accessibility* arrive on the engine's structural event channel
142
- // (Angular blocks [onX] property bindings; events flow through (event) only).
143
- emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
144
+ // Forward an engine event to the matching @Output(), narrowing the untyped event first.
145
+ private emit(emitter: EventEmitter<ISymbioteEvent>, event: unknown): void {
144
146
  if (isSymbioteEvent(event)) emitter.emit(event);
145
147
  }
146
148
 
149
+ // `.observed`-gated, mirroring Pressable's `accessibilityEmitterHandler`: `undefined` here
150
+ // means `setEventListener` never sees a function, so the gated prop's flag never lights
151
+ // (`fabric-boolean-event-gates.md`, "The flag follows the VALUE, not the key").
152
+ private eventEmitterHandler(
153
+ emitter: EventEmitter<ISymbioteEvent>,
154
+ ): ((event: unknown) => void) | undefined {
155
+ return emitter.observed ? event => this.emit(emitter, event) : undefined;
156
+ }
157
+
147
158
  // Bridges the non-reactive fields `hostProps` reads into the reactive graph, so it can memoize.
148
159
  // Plain fields read inside a computed() are UNTRACKED - something must signal "a dependency
149
160
  // changed" or the bag goes stale. Signal inputs would do this natively, but `input()` is visible
@@ -226,6 +237,11 @@ export class SafeAreaView
226
237
  'aria-valuemin': this.ariaValueMin,
227
238
  'aria-valuenow': this.ariaValueNow,
228
239
  'aria-valuetext': this.ariaValueText,
240
+ onLayout: this.eventEmitterHandler(this.layout),
241
+ onAccessibilityAction: this.eventEmitterHandler(this.accessibilityAction),
242
+ onAccessibilityTap: this.eventEmitterHandler(this.accessibilityTap),
243
+ onMagicTap: this.eventEmitterHandler(this.magicTap),
244
+ onAccessibilityEscape: this.eventEmitterHandler(this.accessibilityEscape),
229
245
  });
230
246
  });
231
247
  }
@@ -63,6 +63,11 @@ import {
63
63
  type IViewStyle,
64
64
  } from '@symbiote-native/engine';
65
65
  import { anchorHostStyle, SymbioteHostPropsDirective } from '../../primitives';
66
+ import {
67
+ gateWanted,
68
+ injectGateDemand,
69
+ type IGatedAccessibilityEvent,
70
+ } from '../../gate-demand';
66
71
  import { RefreshControl } from '../refresh-control';
67
72
  import { ScrollViewProjectionController } from './projection';
68
73
 
@@ -369,6 +374,10 @@ export abstract class ScrollViewBase
369
374
  @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
370
375
  @Output() readonly accessibilityEscape = new EventEmitter<ISymbioteEvent>();
371
376
 
377
+ // Null unless an adapter wrapper renders this ScrollView in its own template — VirtualizedList
378
+ // does. An app's own `<ScrollView>` gets null and answers from its own `.observed`.
379
+ private readonly gateDemand = injectGateDemand();
380
+
372
381
  testID: string | undefined;
373
382
  nativeID: string | undefined;
374
383
  accessible: boolean | undefined;
@@ -624,10 +633,14 @@ export abstract class ScrollViewBase
624
633
  onMomentumScrollBegin: this.onMomentumScrollBegin,
625
634
  onMomentumScrollEnd: this.onMomentumScrollEnd,
626
635
  onScrollToTop: this.emitterCallback(this.scrollToTop),
627
- onAccessibilityAction: this.emitterCallback(this.accessibilityAction),
628
- onAccessibilityTap: this.emitterCallback(this.accessibilityTap),
629
- onMagicTap: this.emitterCallback(this.magicTap),
630
- onAccessibilityEscape: this.emitterCallback(this.accessibilityEscape),
636
+ onAccessibilityAction: this.gatedAccessibilityCallback(
637
+ 'accessibilityAction',
638
+ ),
639
+ onAccessibilityTap: this.gatedAccessibilityCallback('accessibilityTap'),
640
+ onMagicTap: this.gatedAccessibilityCallback('magicTap'),
641
+ onAccessibilityEscape: this.gatedAccessibilityCallback(
642
+ 'accessibilityEscape',
643
+ ),
631
644
  });
632
645
 
633
646
  // RN defaults nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`); Android needs
@@ -814,6 +827,20 @@ export abstract class ScrollViewBase
814
827
  // subscribes a template's (event) listeners in its CREATION pass, before any input is written and
815
828
  // before this component's own view is first refreshed - so `observed` is already settled at the
816
829
  // first evaluation and cannot flip afterwards without destroying the whole component.
830
+ // The four GATED accessibility events, which `emitterCallback` cannot answer alone: VirtualizedList
831
+ // renders this component and binds all four, so `.observed` is true on every list in the app
832
+ // whether or not anything listens. A demand from above decides instead — see `gate-demand.ts`.
833
+ //
834
+ // Deliberately NOT applied to the projected RefreshControl's four, one site below: that emitter
835
+ // belongs to a component the APP wrote and projected in, so its `.observed` is honest.
836
+ private gatedAccessibilityCallback(
837
+ name: IGatedAccessibilityEvent,
838
+ ): IScrollHandler | undefined {
839
+ const emitter = this[name];
840
+ if (!gateWanted(this.gateDemand, name, emitter)) return undefined;
841
+ return this.emitterCallback(emitter);
842
+ }
843
+
817
844
  private emitterCallback(
818
845
  emitter: EventEmitter<ISymbioteEvent>,
819
846
  ): IScrollHandler | undefined {