@kouji-ui/core 0.9.0 → 0.9.2

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, input, booleanAttribute, Directive, PLATFORM_ID, signal, DestroyRef, Injectable, ElementRef, afterNextRender, forwardRef, InjectionToken, Injector, computed, effect, untracked, viewChild, ViewContainerRef, ViewEncapsulation, ChangeDetectionStrategy, Component, ApplicationRef, EnvironmentInjector, createComponent, model, isSignal, DOCUMENT as DOCUMENT$1, output, numberAttribute, afterRenderEffect, contentChildren, makeEnvironmentProviders, LOCALE_ID, provideEnvironmentInitializer, contentChild, linkedSignal, PendingTasks, TemplateRef, runInInjectionContext, resource } from '@angular/core';
2
+ import { inject, input, booleanAttribute, Directive, PLATFORM_ID, signal, DestroyRef, Injectable, ElementRef, afterNextRender, untracked, forwardRef, InjectionToken, Injector, computed, effect, viewChild, ViewContainerRef, ViewEncapsulation, ChangeDetectionStrategy, Component, ApplicationRef, EnvironmentInjector, createComponent, model, isSignal, DOCUMENT as DOCUMENT$1, output, numberAttribute, afterRenderEffect, contentChildren, makeEnvironmentProviders, LOCALE_ID, provideEnvironmentInitializer, contentChild, linkedSignal, PendingTasks, TemplateRef, runInInjectionContext, resource } from '@angular/core';
3
3
  import { DOCUMENT, isPlatformBrowser } from '@angular/common';
4
4
  import { NG_VALUE_ACCESSOR, NG_VALIDATORS, NgForm, FormGroupDirective, FormGroup, FormArray } from '@angular/forms';
5
5
  import { Subject } from 'rxjs';
@@ -282,10 +282,13 @@ class KjFocusRing {
282
282
  afterNextRender(() => {
283
283
  if (!isPlatformBrowser(this.platformId))
284
284
  return;
285
- const onFocus = () => {
286
- this._focusVisible.set(this.inputModality.modality() === 'keyboard');
287
- };
288
- const onBlur = () => { this._focusVisible.set(false); };
285
+ // `focus` / `blur` can fire synchronously while Angular renders — a
286
+ // focused element that a binding disables or removes blurs at once in
287
+ // Chromium — and a raw listener inherits the template's reactive
288
+ // context, where a signal write throws NG0600. `untracked` runs the
289
+ // handlers outside it (Angular's own listeners do the same).
290
+ const onFocus = () => untracked(() => this._focusVisible.set(this.inputModality.modality() === 'keyboard'));
291
+ const onBlur = () => untracked(() => this._focusVisible.set(false));
289
292
  const releaseModality = this.inputModality.retain();
290
293
  this.el.nativeElement.addEventListener('focus', onFocus);
291
294
  this.el.nativeElement.addEventListener('blur', onBlur);
@@ -1971,6 +1974,21 @@ function longestRunMs(durations, delays) {
1971
1974
  }
1972
1975
  /** The strategy slots of a bundle, in attach order. */
1973
1976
  const STRATEGY_SLOTS = ['mount', 'position', 'backdrop', 'scrollLock', 'focusTrap', 'liveAnnouncer', 'trigger'];
1977
+ /**
1978
+ * The controller each attached strategy instance serves. A strategy is
1979
+ * stateful (it holds one overlay's context and its inert / scroll-lock
1980
+ * release handles), so one instance must never run two overlays at once.
1981
+ */
1982
+ const strategyOwners = new WeakMap();
1983
+ /**
1984
+ * @internal The controller `strategy` is attached to, if any. `KjOverlayPanel`
1985
+ * uses it to drop strategies it inherited from an enclosing overlay (a
1986
+ * select inside a modal dialog must not run the dialog's scrim, inert and
1987
+ * focus trap as its own).
1988
+ */
1989
+ function overlayStrategyOwner(strategy) {
1990
+ return strategyOwners.get(strategy);
1991
+ }
1974
1992
  /**
1975
1993
  * Owns the overlay's open/closed state machine plus the rAF/transition
1976
1994
  * orchestration. Strategies (mount/position/backdrop/focus-trap/scroll-lock/
@@ -2122,13 +2140,21 @@ class KjOverlayController {
2122
2140
  }
2123
2141
  for (const slot of STRATEGY_SLOTS) {
2124
2142
  const old = prev[slot];
2125
- if (old && old !== next[slot])
2143
+ if (old && old !== next[slot]) {
2126
2144
  old.detach();
2145
+ if (strategyOwners.get(old) === this)
2146
+ strategyOwners.delete(old);
2147
+ }
2127
2148
  }
2128
2149
  }
2129
2150
  this.strategies = next;
2130
- for (const slot of STRATEGY_SLOTS)
2131
- next[slot]?.attach(this.context);
2151
+ for (const slot of STRATEGY_SLOTS) {
2152
+ const strategy = next[slot];
2153
+ if (!strategy)
2154
+ continue;
2155
+ strategyOwners.set(strategy, this);
2156
+ strategy.attach(this.context);
2157
+ }
2132
2158
  }
2133
2159
  open() {
2134
2160
  const cur = this._state();
@@ -2195,7 +2221,12 @@ class KjOverlayController {
2195
2221
  return;
2196
2222
  const s = this.strategies;
2197
2223
  for (const slot of [...STRATEGY_SLOTS].reverse()) {
2198
- s[slot]?.detach();
2224
+ const strategy = s[slot];
2225
+ if (!strategy)
2226
+ continue;
2227
+ strategy.detach();
2228
+ if (strategyOwners.get(strategy) === this)
2229
+ strategyOwners.delete(strategy);
2199
2230
  }
2200
2231
  this.strategies = null;
2201
2232
  }
@@ -2908,6 +2939,36 @@ function resolveTokens(self) {
2908
2939
  role: inject(KJ_OVERLAY_PANEL_ROLE, opts),
2909
2940
  };
2910
2941
  }
2942
+ /**
2943
+ * `tokens` minus every strategy already attached to a controller other than
2944
+ * `controller`. Strategies are resolved through the injector chain, so an
2945
+ * overlay rendered inside another one (a select, combobox or date picker in
2946
+ * a service-launched dialog) would otherwise find the enclosing overlay's
2947
+ * own instances for every slot it does not provide itself — and run the
2948
+ * dialog's scrim, `inert` and focus trap as if the dropdown were a second
2949
+ * modal, freezing the dialog it belongs to. Those instances belong to the
2950
+ * enclosing overlay; the nested one falls back to "none" for that slot.
2951
+ */
2952
+ function withoutForeign(tokens, controller) {
2953
+ if (!controller)
2954
+ return tokens;
2955
+ const mine = (s) => {
2956
+ if (!s)
2957
+ return s;
2958
+ const owner = overlayStrategyOwner(s);
2959
+ return owner && owner !== controller ? null : s;
2960
+ };
2961
+ return {
2962
+ ...tokens,
2963
+ mount: mine(tokens.mount),
2964
+ position: mine(tokens.position),
2965
+ backdrop: mine(tokens.backdrop),
2966
+ focusTrap: mine(tokens.focusTrap),
2967
+ scrollLock: mine(tokens.scrollLock),
2968
+ liveAnnouncer: mine(tokens.liveAnnouncer),
2969
+ trigger: mine(tokens.trigger),
2970
+ };
2971
+ }
2911
2972
  /**
2912
2973
  * Marks an element as the overlay's panel — wires it to the controller,
2913
2974
  * resolves the panel role/aria-modal, and binds DOM state attributes
@@ -2922,7 +2983,10 @@ function resolveTokens(self) {
2922
2983
  * must not inherit the dialog's scroll lock, scrim or `alertdialog` role.
2923
2984
  * A panel whose controller is on its host chain (a builder-launched body,
2924
2985
  * or the root pattern where one directive provides controller and
2925
- * strategies for the whole subtree) reads them from that chain as before.
2986
+ * strategies for the whole subtree) reads them from that chain, minus any
2987
+ * strategy instance an enclosing overlay is already running: a select inside
2988
+ * a service-launched dialog finds the dialog's scrim, focus trap and scroll
2989
+ * lock up the chain, and must not adopt them.
2926
2990
  *
2927
2991
  * A portalled panel whose scope provides a backdrop strategy also gets a
2928
2992
  * real `<kj-backdrop>` scrim (created next to the panel, moved into the
@@ -2977,8 +3041,11 @@ class KjOverlayPanel {
2977
3041
  return !!t && t.controller !== this.hostController;
2978
3042
  }, /* @ts-ignore */
2979
3043
  ...(ngDevMode ? [{ debugName: "scoped" }] : /* istanbul ignore next */ []));
2980
- /** The tokens that apply to this panel. */
2981
- tokens = computed(() => (this.scoped() ? this.own : this.inherited), /* @ts-ignore */
3044
+ /**
3045
+ * The tokens that apply to this panel. Inherited ones exclude strategies an
3046
+ * enclosing overlay already runs (see {@link withoutForeign}).
3047
+ */
3048
+ tokens = computed(() => this.scoped() ? this.own : withoutForeign(this.inherited, this.hostController), /* @ts-ignore */
2982
3049
  ...(ngDevMode ? [{ debugName: "tokens" }] : /* istanbul ignore next */ []));
2983
3050
  role = computed(() => this.tokens().role ?? 'dialog', /* @ts-ignore */
2984
3051
  ...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
@@ -3105,7 +3172,9 @@ function releaseInert(el) {
3105
3172
  * Makes everything behind a modal `panel` inert: every child of `<body>`
3106
3173
  * outside the overlay container (except script/style nodes and live
3107
3174
  * regions), plus every overlay wrapper opened before the panel's own — a
3108
- * dialog stacked on a dialog freezes the one below. A body child that
3175
+ * dialog stacked on a dialog freezes the one below. The container itself is
3176
+ * never inerted, even for a panel mounted outside it, so overlays the modal
3177
+ * opens afterwards (dropdowns, pickers) stay usable. A body child that
3109
3178
  * contains the panel (an inline-mounted modal) is left alone, because
3110
3179
  * inerting it would inert the panel itself.
3111
3180
  *
@@ -3117,10 +3186,19 @@ function inertSiblingsOf(panel) {
3117
3186
  const body = panel.ownerDocument?.body;
3118
3187
  if (!body)
3119
3188
  return () => { };
3120
- const container = panel.closest('.kj-overlay-container');
3189
+ // Overlay roots are never inerted, whether or not the panel lives in one.
3190
+ // An inline-mounted (`inPlace`) modal sits outside the root, yet the
3191
+ // overlays it opens — a select, combobox or date picker dropdown — mount
3192
+ // into it after the modal opened: inerting the root would freeze them. The
3193
+ // wrappers open at this point (below the modal) are frozen one by one
3194
+ // instead; later ones stay live.
3195
+ const containers = Array.from(body.children).filter(isOverlayContainer);
3196
+ const own = panel.closest('.kj-overlay-container');
3197
+ if (own && !containers.includes(own))
3198
+ containers.push(own);
3121
3199
  const targets = [];
3122
3200
  for (const child of Array.from(body.children)) {
3123
- if (child === container)
3201
+ if (isOverlayContainer(child))
3124
3202
  continue;
3125
3203
  if (child.contains(panel))
3126
3204
  continue;
@@ -3130,12 +3208,13 @@ function inertSiblingsOf(panel) {
3130
3208
  continue;
3131
3209
  targets.push(child);
3132
3210
  }
3133
- if (container) {
3134
- const own = Array.from(container.children).find((w) => w.contains(panel));
3211
+ for (const container of containers) {
3212
+ const ownWrapper = Array.from(container.children).find((w) => w.contains(panel));
3135
3213
  for (const wrapper of Array.from(container.children)) {
3136
- if (wrapper === own)
3214
+ if (wrapper === ownWrapper)
3137
3215
  continue;
3138
- const below = !own || !!(wrapper.compareDocumentPosition(own) & Node.DOCUMENT_POSITION_FOLLOWING);
3216
+ const below = !ownWrapper ||
3217
+ !!(wrapper.compareDocumentPosition(ownWrapper) & Node.DOCUMENT_POSITION_FOLLOWING);
3139
3218
  if (below)
3140
3219
  targets.push(wrapper);
3141
3220
  }
@@ -3151,6 +3230,10 @@ function inertSiblingsOf(panel) {
3151
3230
  releaseInert(el);
3152
3231
  };
3153
3232
  }
3233
+ /** Whether `el` is an overlay root (`getOverlayContainer()`'s element, or a look-alike). */
3234
+ function isOverlayContainer(el) {
3235
+ return (el.hasAttribute('data-kj-overlay-container') || el.classList.contains('kj-overlay-container'));
3236
+ }
3154
3237
 
3155
3238
  /**
3156
3239
  * Mount strategy for declarative overlays whose panel is rendered inline
@@ -4515,6 +4598,9 @@ const read$2 = (v, fallback) => {
4515
4598
  * do not bubble, and elements with `display: contents` (e.g. `<kj-button>`)
4516
4599
  * never receive these events because they have no rendered box. Walk to the
4517
4600
  * first descendant with a layout box.
4601
+ *
4602
+ * Measures, so it forces a layout: call it only on a hover, never on attach —
4603
+ * a table with a hover trigger per row would otherwise lay out once per row.
4518
4604
  */
4519
4605
  const effectiveHoverTarget = (el) => {
4520
4606
  if (!el.ownerDocument?.defaultView)
@@ -4542,7 +4628,9 @@ function onHover(initialOpts = {}) {
4542
4628
  let openTimer = 0, closeTimer = 0;
4543
4629
  let onEnter = null;
4544
4630
  let onLeave = null;
4545
- let listenTarget = null;
4631
+ let onFirstOver = null;
4632
+ let triggerTarget = null;
4633
+ let boxTarget = null;
4546
4634
  let panelTarget = null;
4547
4635
  const cancelClose = () => {
4548
4636
  if (closeTimer) {
@@ -4584,11 +4672,12 @@ function onHover(initialOpts = {}) {
4584
4672
  const trigger = ctx.triggerEl();
4585
4673
  if (!trigger || onEnter)
4586
4674
  return;
4587
- listenTarget = effectiveHoverTarget(trigger);
4588
4675
  onEnter = () => {
4589
4676
  cancelClose();
4590
4677
  wirePanel();
4591
- if (ctx?.isOpen())
4678
+ // The first hover can reach here twice (the bubbled `pointerover` that
4679
+ // resolves the box target, then that target's own `pointerenter`).
4680
+ if (ctx?.isOpen() || openTimer)
4592
4681
  return;
4593
4682
  openTimer = setTimeout(() => {
4594
4683
  openTimer = 0;
@@ -4602,8 +4691,27 @@ function onHover(initialOpts = {}) {
4602
4691
  wirePanel();
4603
4692
  scheduleClose();
4604
4693
  };
4605
- listenTarget.addEventListener('pointerenter', onEnter);
4606
- listenTarget.addEventListener('pointerleave', onLeave);
4694
+ // Nothing is measured here. The trigger listens directly (enough when it
4695
+ // has a box); a `display: contents` trigger only sees the bubbled
4696
+ // `pointerover` of a descendant, so the first one resolves — and measures,
4697
+ // once — the descendant that actually receives enter / leave.
4698
+ onFirstOver = () => {
4699
+ if (!onEnter || !onLeave || !triggerTarget)
4700
+ return;
4701
+ triggerTarget.removeEventListener('pointerover', onFirstOver);
4702
+ onFirstOver = null;
4703
+ const target = effectiveHoverTarget(triggerTarget);
4704
+ if (target !== triggerTarget) {
4705
+ boxTarget = target;
4706
+ target.addEventListener('pointerenter', onEnter);
4707
+ target.addEventListener('pointerleave', onLeave);
4708
+ }
4709
+ onEnter();
4710
+ };
4711
+ triggerTarget = trigger;
4712
+ trigger.addEventListener('pointerenter', onEnter);
4713
+ trigger.addEventListener('pointerleave', onLeave);
4714
+ trigger.addEventListener('pointerover', onFirstOver);
4607
4715
  };
4608
4716
  return {
4609
4717
  ariaHasPopup: null,
@@ -4618,10 +4726,16 @@ function onHover(initialOpts = {}) {
4618
4726
  onOpen() { },
4619
4727
  onClose() { },
4620
4728
  detach() {
4621
- if (listenTarget && onEnter)
4622
- listenTarget.removeEventListener('pointerenter', onEnter);
4623
- if (listenTarget && onLeave)
4624
- listenTarget.removeEventListener('pointerleave', onLeave);
4729
+ for (const el of [triggerTarget, boxTarget]) {
4730
+ if (!el)
4731
+ continue;
4732
+ if (onEnter)
4733
+ el.removeEventListener('pointerenter', onEnter);
4734
+ if (onLeave)
4735
+ el.removeEventListener('pointerleave', onLeave);
4736
+ }
4737
+ if (triggerTarget && onFirstOver)
4738
+ triggerTarget.removeEventListener('pointerover', onFirstOver);
4625
4739
  if (panelTarget) {
4626
4740
  panelTarget.removeEventListener('pointerenter', cancelClose);
4627
4741
  panelTarget.removeEventListener('pointerleave', scheduleClose);
@@ -4630,8 +4744,8 @@ function onHover(initialOpts = {}) {
4630
4744
  clearTimeout(openTimer);
4631
4745
  if (closeTimer)
4632
4746
  clearTimeout(closeTimer);
4633
- onEnter = onLeave = null;
4634
- listenTarget = null;
4747
+ onEnter = onLeave = onFirstOver = null;
4748
+ triggerTarget = boxTarget = null;
4635
4749
  panelTarget = null;
4636
4750
  openTimer = closeTimer = 0;
4637
4751
  toggle = null;
@@ -22495,7 +22609,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
22495
22609
  }]
22496
22610
  }], propDecorators: { kjCell: [{ type: i0.Input, args: [{ isSignal: true, alias: "kjCell", required: true }] }] } });
22497
22611
 
22498
- /** `Node.DOCUMENT_POSITION_PRECEDING` — inlined so the DOM-order sort also runs on the server. */
22612
+ /** `Node.DOCUMENT_POSITION_PRECEDING`, inlined so the module never touches the `Node` global. */
22499
22613
  const DOCUMENT_POSITION_PRECEDING = 2;
22500
22614
  /** Text-entry controls whose own arrow / Home / End handling must win over grid navigation. */
22501
22615
  const TEXT_ENTRY_SELECTOR = 'input:not([type="button"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="reset"]), textarea, select, [contenteditable]:not([contenteditable="false"])';
@@ -22533,23 +22647,32 @@ class KjTableKeyboardNav {
22533
22647
  /** Id of the cell the user focused last; survives the cell leaving the DOM. */
22534
22648
  activeId = signal(null, /* @ts-ignore */
22535
22649
  ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
22650
+ /** Tab stop resolved after the last render (see {@link tabStopId}). */
22651
+ resolvedStopId = signal(null, /* @ts-ignore */
22652
+ ...(ngDevMode ? [{ debugName: "resolvedStopId" }] : /* istanbul ignore next */ []));
22536
22653
  /**
22537
22654
  * Id of the cell that owns `tabindex="0"`: the last focused cell while it
22538
22655
  * is rendered, else the first rendered cell in DOM order.
22656
+ *
22657
+ * Every cell reads this, so it must not touch the `cells` query: each
22658
+ * stamped cell view dirties the query, and a per-cell read would re-collect
22659
+ * it and re-sort the DOM once per cell — O(n²) for an n-cell body. It is
22660
+ * resolved once per render instead, after the cells are in the DOM. Until
22661
+ * then (and on the server) no cell is a Tab stop.
22539
22662
  */
22540
- tabStopId = computed(() => {
22541
- const cells = this.cells();
22542
- if (cells.length === 0)
22543
- return null;
22544
- const active = this.activeId();
22545
- if (active !== null && cells.some((c) => c.cellId() === active))
22546
- return active;
22547
- return firstInDomOrder(cells).cellId();
22548
- }, /* @ts-ignore */
22549
- ...(ngDevMode ? [{ debugName: "tabStopId" }] : /* istanbul ignore next */ []));
22663
+ tabStopId = this.resolvedStopId.asReadonly();
22664
+ constructor() {
22665
+ afterRenderEffect(() => {
22666
+ const cells = this.cells();
22667
+ const active = this.activeId();
22668
+ this.resolvedStopId.set(resolveTabStop(cells, active));
22669
+ });
22670
+ }
22550
22671
  /** Record `cellId` as the last focused cell. @param cellId TanStack `cell.id`. */
22551
22672
  setActive(cellId) {
22552
22673
  this.activeId.set(cellId);
22674
+ // A cell that just took focus is rendered: it is the Tab stop right away.
22675
+ this.resolvedStopId.set(cellId);
22553
22676
  }
22554
22677
  onKeyDown(event) {
22555
22678
  if (event.defaultPrevented)
@@ -22651,7 +22774,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
22651
22774
  providers: [{ provide: KJ_TABLE_KEYBOARD_NAV, useExisting: KjTableKeyboardNav }],
22652
22775
  host: { '(keydown)': 'onKeyDown($event)' },
22653
22776
  }]
22654
- }], propDecorators: { cells: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => KjTableCell), { ...{ descendants: true }, isSignal: true }] }] } });
22777
+ }], ctorParameters: () => [], propDecorators: { cells: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => KjTableCell), { ...{ descendants: true }, isSignal: true }] }] } });
22778
+ /** The active cell while it is rendered, else the first rendered cell in DOM order. */
22779
+ function resolveTabStop(cells, active) {
22780
+ if (cells.length === 0)
22781
+ return null;
22782
+ if (active !== null && cells.some((c) => c.cellId() === active))
22783
+ return active;
22784
+ return firstInDomOrder(cells).cellId();
22785
+ }
22655
22786
  /**
22656
22787
  * The first cell in DOM order. Query results follow declaration order, which
22657
22788
  * a row template stamped into several bodies (pinned top, centre, pinned