@kouji-ui/core 0.9.1 → 0.10.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.
@@ -1974,6 +1974,21 @@ function longestRunMs(durations, delays) {
1974
1974
  }
1975
1975
  /** The strategy slots of a bundle, in attach order. */
1976
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
+ }
1977
1992
  /**
1978
1993
  * Owns the overlay's open/closed state machine plus the rAF/transition
1979
1994
  * orchestration. Strategies (mount/position/backdrop/focus-trap/scroll-lock/
@@ -2125,13 +2140,21 @@ class KjOverlayController {
2125
2140
  }
2126
2141
  for (const slot of STRATEGY_SLOTS) {
2127
2142
  const old = prev[slot];
2128
- if (old && old !== next[slot])
2143
+ if (old && old !== next[slot]) {
2129
2144
  old.detach();
2145
+ if (strategyOwners.get(old) === this)
2146
+ strategyOwners.delete(old);
2147
+ }
2130
2148
  }
2131
2149
  }
2132
2150
  this.strategies = next;
2133
- for (const slot of STRATEGY_SLOTS)
2134
- 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
+ }
2135
2158
  }
2136
2159
  open() {
2137
2160
  const cur = this._state();
@@ -2198,7 +2221,12 @@ class KjOverlayController {
2198
2221
  return;
2199
2222
  const s = this.strategies;
2200
2223
  for (const slot of [...STRATEGY_SLOTS].reverse()) {
2201
- 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);
2202
2230
  }
2203
2231
  this.strategies = null;
2204
2232
  }
@@ -2911,6 +2939,36 @@ function resolveTokens(self) {
2911
2939
  role: inject(KJ_OVERLAY_PANEL_ROLE, opts),
2912
2940
  };
2913
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
+ }
2914
2972
  /**
2915
2973
  * Marks an element as the overlay's panel — wires it to the controller,
2916
2974
  * resolves the panel role/aria-modal, and binds DOM state attributes
@@ -2925,7 +2983,10 @@ function resolveTokens(self) {
2925
2983
  * must not inherit the dialog's scroll lock, scrim or `alertdialog` role.
2926
2984
  * A panel whose controller is on its host chain (a builder-launched body,
2927
2985
  * or the root pattern where one directive provides controller and
2928
- * 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.
2929
2990
  *
2930
2991
  * A portalled panel whose scope provides a backdrop strategy also gets a
2931
2992
  * real `<kj-backdrop>` scrim (created next to the panel, moved into the
@@ -2980,8 +3041,11 @@ class KjOverlayPanel {
2980
3041
  return !!t && t.controller !== this.hostController;
2981
3042
  }, /* @ts-ignore */
2982
3043
  ...(ngDevMode ? [{ debugName: "scoped" }] : /* istanbul ignore next */ []));
2983
- /** The tokens that apply to this panel. */
2984
- 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 */
2985
3049
  ...(ngDevMode ? [{ debugName: "tokens" }] : /* istanbul ignore next */ []));
2986
3050
  role = computed(() => this.tokens().role ?? 'dialog', /* @ts-ignore */
2987
3051
  ...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
@@ -22092,6 +22156,54 @@ class KjTable {
22092
22156
  setRowCount(n) {
22093
22157
  this._rowCount.set(n);
22094
22158
  }
22159
+ /**
22160
+ * Server-side infinite mode. When on, `kjTableData` is index-aligned with
22161
+ * the server's result set and may hold holes (`undefined` / `null`) for
22162
+ * rows that are not loaded yet: the holes are dropped before the rows reach
22163
+ * TanStack, and {@link sourceIndex} maps each loaded row back to its
22164
+ * position in the full result set. Sorting, filtering and pagination are
22165
+ * left to the server (TanStack's `manual*` flags), so a header click only
22166
+ * changes `state.sorting()` — feed it into your loader's request.
22167
+ */
22168
+ _infinite = signal(false, /* @ts-ignore */
22169
+ ...(ngDevMode ? [{ debugName: "_infinite" }] : /* istanbul ignore next */ []));
22170
+ /** Whether server-side infinite mode is on — see {@link setInfinite}. */
22171
+ infinite = this._infinite.asReadonly();
22172
+ /**
22173
+ * Turn server-side infinite mode on or off.
22174
+ * @param on `true` while the data is a sparse window of a server result set.
22175
+ */
22176
+ setInfinite(on) {
22177
+ this._infinite.set(on);
22178
+ }
22179
+ /** Rows handed to TanStack plus, in infinite mode, their positions in the full result set. */
22180
+ compacted = computed(() => {
22181
+ const data = this.kjTableData();
22182
+ if (!this._infinite())
22183
+ return { rows: data, sourceIndex: null };
22184
+ const rows = [];
22185
+ const sourceIndex = [];
22186
+ // `forEach` skips true holes, so a `new Array(total)` window costs only
22187
+ // its loaded entries.
22188
+ data.forEach((row, i) => {
22189
+ if (row == null)
22190
+ return;
22191
+ rows.push(row);
22192
+ sourceIndex.push(i);
22193
+ });
22194
+ return { rows, sourceIndex };
22195
+ }, /* @ts-ignore */
22196
+ ...(ngDevMode ? [{ debugName: "compacted" }] : /* istanbul ignore next */ []));
22197
+ /**
22198
+ * In infinite mode, the result-set position of each loaded top-level row,
22199
+ * indexed by its TanStack `row.index`; `null` otherwise.
22200
+ */
22201
+ sourceIndex = computed(() => this.compacted().sourceIndex, /* @ts-ignore */
22202
+ ...(ngDevMode ? [{ debugName: "sourceIndex" }] : /* istanbul ignore next */ []));
22203
+ /** Position of a top-level row in the full result set (its `row.index` outside infinite mode). */
22204
+ sourceIndexOf(index) {
22205
+ return this.compacted().sourceIndex?.[index] ?? index;
22206
+ }
22095
22207
  /**
22096
22208
  * Apply a TanStack updater (value or function) to a single state slice.
22097
22209
  *
@@ -22134,11 +22246,15 @@ class KjTable {
22134
22246
  ...(ngDevMode ? [{ debugName: "boundColumns" }] : /* istanbul ignore next */ []));
22135
22247
  /** The TanStack table instance. Access rows, headers, and state here. */
22136
22248
  table = createAngularTable(() => ({
22137
- data: this.kjTableData(),
22249
+ data: this.compacted().rows,
22138
22250
  columns: this.boundColumns(),
22251
+ // In infinite mode a top-level row's index is its result-set position,
22252
+ // so default ids stay stable while pages load around them.
22139
22253
  getRowId: this.kjGetRowId()
22140
- ? (row, index) => this.kjGetRowId()(row, index)
22141
- : undefined,
22254
+ ? (row, index, parent) => this.kjGetRowId()(row, parent ? index : this.sourceIndexOf(index))
22255
+ : this._infinite()
22256
+ ? (_row, index, parent) => (parent ? `${parent.id}.${index}` : String(this.sourceIndexOf(index)))
22257
+ : undefined,
22142
22258
  // `columnResizeMode: 'onEnd'` defers the actual column width write until
22143
22259
  // the user releases the resize handle. While dragging, the wrapper reads
22144
22260
  // `state.columnSizingInfo().deltaOffset` to render a vertical preview
@@ -22148,7 +22264,9 @@ class KjTable {
22148
22264
  // TanStack to manual pagination so the loader-page becomes the
22149
22265
  // visible page (no client-side slicing) and pagination controls
22150
22266
  // derive their totals from the remote count.
22151
- manualPagination: this._rowCount() !== null,
22267
+ manualPagination: this._rowCount() !== null || this._infinite(),
22268
+ manualSorting: this._infinite(),
22269
+ manualFiltering: this._infinite(),
22152
22270
  rowCount: this._rowCount() ?? undefined,
22153
22271
  state: { ...this._state(), columnSizingInfo: this._columnSizingInfo() },
22154
22272
  onSortingChange: u => this.patch('sorting', u),
@@ -22450,7 +22568,12 @@ class KjTableRow {
22450
22568
  ...(ngDevMode ? [{ debugName: "kjRow" }] : /* istanbul ignore next */ []));
22451
22569
  table = injectParent(KJ_TABLE, { child: 'KjTableRow', parent: '[kjTable]' });
22452
22570
  /** 1-based ARIA index — accounts for header row(s). */
22453
- ariaRowIndex = computed(() => this.kjRow().index + 2, /* @ts-ignore */
22571
+ ariaRowIndex = computed(() => {
22572
+ const row = this.kjRow();
22573
+ // Infinite mode: the row's place in the whole result set, not in the loaded window.
22574
+ const source = row.depth === 0 ? this.table.sourceIndex()?.[row.index] : undefined;
22575
+ return (source ?? row.index) + 2;
22576
+ }, /* @ts-ignore */
22454
22577
  ...(ngDevMode ? [{ debugName: "ariaRowIndex" }] : /* istanbul ignore next */ []));
22455
22578
  isSelectable = computed(() => Object.keys(this.table.state.rowSelection()).length >= 0, /* @ts-ignore */
22456
22579
  ...(ngDevMode ? [{ debugName: "isSelectable" }] : /* istanbul ignore next */ [])); // always true if selection enabled