@kouji-ui/core 0.13.0 → 0.13.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.
@@ -1141,7 +1141,7 @@ class KjOverlayStack {
1141
1141
  pressEntry = null;
1142
1142
  register(id, opts) {
1143
1143
  if (!this.isBrowser) {
1144
- return { unregister: () => { }, isTopmost: computed(() => false), zIndex: this.configuredBase };
1144
+ return { unregister: () => { }, release: () => { }, isTopmost: computed(() => false), zIndex: this.configuredBase };
1145
1145
  }
1146
1146
  const entry = {
1147
1147
  id,
@@ -1168,6 +1168,12 @@ class KjOverlayStack {
1168
1168
  }
1169
1169
  this.maybeRemoveListeners();
1170
1170
  },
1171
+ release: () => {
1172
+ if (entry.opts.passive || !this._stack().includes(entry))
1173
+ return;
1174
+ entry.opts.passive = true;
1175
+ this._stack.update(s => [...s]);
1176
+ },
1171
1177
  isTopmost,
1172
1178
  zIndex: entry.zIndex,
1173
1179
  };
@@ -1254,9 +1260,13 @@ class KjOverlayStack {
1254
1260
  if (e.key !== 'Escape')
1255
1261
  return;
1256
1262
  const top = this.topmost();
1257
- if (!top || !top.opts.closeOnEsc)
1263
+ if (!top)
1258
1264
  return;
1259
- top.opts.onClose('escape');
1265
+ // Handled (closed, or swallowed by an overlay that must be answered):
1266
+ // app-level Escape listeners must not also close what sits behind it.
1267
+ e.preventDefault();
1268
+ if (top.opts.closeOnEsc)
1269
+ top.opts.onClose('escape');
1260
1270
  }
1261
1271
  /** Is `target` a press outside `entry`'s content — a candidate dismissal? */
1262
1272
  isOutside(entry, target) {
@@ -2281,6 +2291,13 @@ class KjOverlayController {
2281
2291
  beginClose() {
2282
2292
  if (!this.strategies)
2283
2293
  return;
2294
+ // A closing overlay no longer owns Escape / outside presses: one made
2295
+ // during its close animation belongs to what is beneath it. Released
2296
+ // after the current event, so the press that closed it does not also
2297
+ // reach the overlay beneath (its backdrop, its own Escape handler).
2298
+ const handle = this.stackHandle;
2299
+ if (handle)
2300
+ setTimeout(() => handle.release());
2284
2301
  this.runTransition('close', () => this.finishClose());
2285
2302
  }
2286
2303
  /**
@@ -26999,6 +27016,15 @@ class KjCombobox {
26999
27016
  /** Filter-aware visible items, exposed for KjListNavigatorConfig. */
27000
27017
  visibleItems = computed(() => this.filter.visibleItems(), /* @ts-ignore */
27001
27018
  ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
27019
+ /**
27020
+ * Text the user typed since the list last closed or a value was picked —
27021
+ * the only text that filters the list. `kjQuery` also carries the picked
27022
+ * row's name (so the field reads as a picker), and filtering by it would
27023
+ * list nothing but the picked row on every later open.
27024
+ */
27025
+ _typed = signal('', /* @ts-ignore */
27026
+ ...(ngDevMode ? [{ debugName: "_typed" }] : /* istanbul ignore next */ []));
27027
+ filterQuery = this._typed.asReadonly();
27002
27028
  /** Public surface read by children. */
27003
27029
  query = this.kjQuery.asReadonly();
27004
27030
  open = this.controller.isOpen;
@@ -27053,7 +27079,7 @@ class KjCombobox {
27053
27079
  constructor() {
27054
27080
  this.filter.bind({
27055
27081
  items: this.items,
27056
- query: this.kjQuery,
27082
+ query: this._typed,
27057
27083
  filterFn: this.adaptedFilter,
27058
27084
  // A windowed wrapper has already filtered the DATA; every rendered
27059
27085
  // row is a match by construction.
@@ -27067,55 +27093,69 @@ class KjCombobox {
27067
27093
  compareBy: this.compareBy,
27068
27094
  });
27069
27095
  effect(() => this.kjQueryChange.emit(this.kjQuery()));
27070
- // Auto-activate first visible item on query change so Enter can
27071
- // commit it without an explicit ArrowDown press. APG combobox 1.2
27072
- // discoverability pattern.
27096
+ // Typed text lasts until the list closes (a pick closes it too).
27073
27097
  effect(() => {
27074
- this.kjQuery();
27075
- const autoFirst = this.kjAutoActivateFirst();
27076
- const nav = this._nav();
27077
- if (!autoFirst || !nav || this._virtual() !== null)
27078
- return;
27079
- untracked(() => {
27080
- const visible = this.filter.visibleItems();
27081
- nav.setActive(visible.length ? visible[0].id : null);
27082
- });
27098
+ if (!this.open())
27099
+ untracked(() => this._typed.set(''));
27083
27100
  });
27084
- // Seed the active item once the navigator attaches (input mounts).
27101
+ // Place the cursor on open, on typing and once the navigator attaches.
27102
+ // Typed text: the first match, so Enter commits it without an ArrowDown
27103
+ // (APG combobox 1.2). No typed text: the picked row when it is listed,
27104
+ // else the first — never the first row while another one is picked, or
27105
+ // two rows read as chosen.
27085
27106
  effect(() => {
27107
+ const typed = this._typed();
27108
+ this.open();
27086
27109
  const nav = this._nav();
27087
- if (!nav || !this.kjAutoActivateFirst() || this._virtual() !== null)
27088
- return;
27089
- if (nav.activeId() !== null)
27110
+ if (!this.kjAutoActivateFirst() || !nav || this._virtual() !== null)
27090
27111
  return;
27091
27112
  untracked(() => {
27092
27113
  const visible = this.filter.visibleItems();
27093
- if (visible.length > 0)
27094
- nav.setActive(visible[0].id);
27114
+ const picked = typed === '' ? visible.find(i => this.isPicked(i)) : undefined;
27115
+ nav.setActive((picked ?? visible[0])?.id ?? null);
27095
27116
  });
27096
27117
  });
27097
- // Seed `kjQuery` from the selected item's label whenever the input
27098
- // would otherwise render blank. Covers the case where `kjValue` is
27099
- // bound up-front (preset) but no query text has been typed yet.
27100
- // A windowed wrapper owns this too: `items()` is a window, so a preset
27101
- // value whose row is scrolled out would never be found here.
27118
+ // Write the picked row's name into the field when the VALUE changes, or
27119
+ // when that row's text first renders (`label()` falls back to the row's
27120
+ // text after its first render). Never because the user emptied the
27121
+ // field: an empty field lists every option. A windowed wrapper owns this
27122
+ // too: `items()` is a window, so a value whose row is scrolled out would
27123
+ // never be found here.
27124
+ let shown = null;
27125
+ let seeded = false;
27102
27126
  effect(() => {
27103
- const items = this.items();
27104
27127
  const v = this.kjValue();
27105
- if (v === null || v === undefined || this._virtual() !== null)
27128
+ if (v === null || v === undefined) {
27129
+ shown = null;
27130
+ return;
27131
+ }
27132
+ if (this._virtual() !== null)
27106
27133
  return;
27107
- if (this.kjQuery() !== '')
27134
+ const label = this.items().find(i => i.value() === v)?.label();
27135
+ if (!label)
27108
27136
  return;
27137
+ if (shown && Object.is(shown.value, v) && shown.label === label)
27138
+ return;
27139
+ shown = { value: v, label };
27140
+ const first = !seeded;
27141
+ seeded = true;
27109
27142
  untracked(() => {
27110
- const match = items.find(i => i.value() === v);
27111
- if (match)
27112
- this.kjQuery.set(match.label());
27143
+ // A consumer-bound `kjQuery` beats the preset value's name on first render.
27144
+ if (first && this.kjQuery() !== '')
27145
+ return;
27146
+ this.kjQuery.set(label);
27113
27147
  });
27114
27148
  });
27115
27149
  }
27150
+ isPicked(item) {
27151
+ const v = item.value();
27152
+ return v !== undefined && this._selection.isSelected(v);
27153
+ }
27154
+ /** Text typed into the input: sets the query, filters the list by it and opens it. */
27116
27155
  setQuery(value) {
27117
27156
  if (this.kjQuery() !== value)
27118
27157
  this.kjQuery.set(value);
27158
+ this._typed.set(value);
27119
27159
  this.controller.open();
27120
27160
  }
27121
27161
  /**
@@ -27349,6 +27389,17 @@ class KjComboboxListbox {
27349
27389
  offset: this.kjOffset,
27350
27390
  matchTriggerWidth: 'min',
27351
27391
  });
27392
+ // Keep the cursor's row in view: `aria-activedescendant` never moves DOM
27393
+ // focus, so nothing else scrolls the list to it (a picked row far down the
27394
+ // list on open, ArrowDown past the last visible row).
27395
+ const doc = inject(DOCUMENT);
27396
+ const injector = inject(Injector);
27397
+ effect(() => {
27398
+ const id = this.ctx.activeId();
27399
+ if (!id || !this.ctx.open())
27400
+ return;
27401
+ afterNextRender(() => doc.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }), { injector });
27402
+ });
27352
27403
  }
27353
27404
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: KjComboboxListbox, deps: [], target: i0.ɵɵFactoryTarget.Directive });
27354
27405
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.5", type: KjComboboxListbox, isStandalone: true, selector: "[kjComboboxListbox]", inputs: { kjSide: { classPropertyName: "kjSide", publicName: "kjSide", isSignal: true, isRequired: false, transformFunction: null }, kjAlign: { classPropertyName: "kjAlign", publicName: "kjAlign", isSignal: true, isRequired: false, transformFunction: null }, kjOffset: { classPropertyName: "kjOffset", publicName: "kjOffset", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "mousedown": "$event.preventDefault()" } }, providers: [