myoperator-mcp 0.2.375 → 0.2.377

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 (2) hide show
  1. package/dist/index.js +130 -30
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6780,6 +6780,14 @@ const MENU_MIN_HEIGHT = 120;
6780
6780
  */
6781
6781
  const SCROLL_END_THRESHOLD_PX = 48;
6782
6782
 
6783
+ /**
6784
+ * How long to wait after a page lands before re-measuring the list. React has
6785
+ * committed the new rows by the time the effect runs, but the browser has not
6786
+ * necessarily laid them out, so \`scrollHeight\` can still read the pre-growth
6787
+ * value. One frame is usually enough; 50ms is a cheap margin.
6788
+ */
6789
+ const POST_FETCH_MEASURE_DELAY_MS = 50;
6790
+
6783
6791
  /**
6784
6792
  * MultiSelect trigger variants matching TextField styling
6785
6793
  */
@@ -6853,6 +6861,18 @@ export interface MultiSelectProps extends VariantProps<
6853
6861
  searchable?: boolean;
6854
6862
  /** Search placeholder text */
6855
6863
  searchPlaceholder?: string;
6864
+ /**
6865
+ * Controlled search value. Pair with \`onSearchQueryChange\` when filtering
6866
+ * happens server-side (e.g. alongside \`onScrollEnd\` pagination, where each
6867
+ * page only has a slice of the full result set \u2014 client-side filtering
6868
+ * would search just that slice and show false "No results found" states).
6869
+ * When provided, the component stops managing its own search state and
6870
+ * stops filtering \`options\` itself; the caller is expected to pass already
6871
+ * filtered \`options\` for the current \`searchQuery\`.
6872
+ */
6873
+ searchQuery?: string;
6874
+ /** Fires on every search input change. Required to pair with \`searchQuery\`. */
6875
+ onSearchQueryChange?: (query: string) => void;
6856
6876
  /**
6857
6877
  * When set, the trigger shows a single compact summary (e.g. "3 lines
6858
6878
  * selected") instead of one chip per selection; hovering it reveals the full
@@ -6945,6 +6965,8 @@ const MultiSelect = React.forwardRef(
6945
6965
  options,
6946
6966
  searchable,
6947
6967
  searchPlaceholder = "Search...",
6968
+ searchQuery: searchQueryProp,
6969
+ onSearchQueryChange,
6948
6970
  selectAllLabel,
6949
6971
  summaryLabel,
6950
6972
  maxSelections,
@@ -6970,8 +6992,20 @@ const MultiSelect = React.forwardRef(
6970
6992
  React.useState<string[]>(defaultValue);
6971
6993
  // Dropdown open state
6972
6994
  const [isOpen, setIsOpen] = React.useState(false);
6973
- // Search query
6974
- const [searchQuery, setSearchQuery] = React.useState("");
6995
+ // Search query \u2014 controlled when the caller passes \`searchQuery\` (server-side
6996
+ // filtering), uncontrolled otherwise.
6997
+ const [internalSearchQuery, setInternalSearchQuery] = React.useState("");
6998
+ const isSearchControlled = searchQueryProp !== undefined;
6999
+ const searchQuery = isSearchControlled ? searchQueryProp : internalSearchQuery;
7000
+ const updateSearchQuery = React.useCallback(
7001
+ (next: string) => {
7002
+ if (!isSearchControlled) {
7003
+ setInternalSearchQuery(next);
7004
+ }
7005
+ onSearchQueryChange?.(next);
7006
+ },
7007
+ [isSearchControlled, onSearchQueryChange]
7008
+ );
6975
7009
 
6976
7010
  // \`detailed\` rows are a single-line design, so they truncate unless the
6977
7011
  // caller opts out; \`simple\` rows wrap the full label unless asked not to.
@@ -7091,11 +7125,32 @@ const MultiSelect = React.forwardRef(
7091
7125
  const listRef = React.useRef<HTMLDivElement | null>(null);
7092
7126
  /**
7093
7127
  * True once \`onScrollEnd\` has fired for the current visit to the bottom.
7094
- * Cleared only when the user scrolls back out of the threshold zone, so
7095
- * trackpad inertia \u2014 which keeps firing \`scroll\` for hundreds of ms after
7096
- * the finger lifts \u2014 cannot re-trigger the callback.
7128
+ * Cleared when the user scrolls back out of the threshold zone, or when a
7129
+ * landed page pushes the bottom back out of reach, so trackpad inertia \u2014
7130
+ * which keeps firing \`scroll\` for hundreds of ms after the finger lifts \u2014
7131
+ * cannot re-trigger the callback.
7097
7132
  */
7098
7133
  const isLatchedRef = React.useRef(false);
7134
+ /** Previous \`loadingMore\`, so the re-measure knows a page just landed. */
7135
+ const wasLoadingMoreRef = React.useRef(false);
7136
+ /** Pending \`requestAnimationFrame\` id for the coalesced scroll handler. */
7137
+ const scrollFrameRef = React.useRef<number | null>(null);
7138
+ /**
7139
+ * \`onScrollEnd\` read from a timer rather than from the closure, so an
7140
+ * inline arrow from the consumer cannot restart the post-fetch timer on
7141
+ * every render.
7142
+ */
7143
+ const onScrollEndRef = React.useRef(onScrollEnd);
7144
+ React.useEffect(() => {
7145
+ onScrollEndRef.current = onScrollEnd;
7146
+ }, [onScrollEnd]);
7147
+
7148
+ /** Distance in px from the current scroll position to the list bottom. */
7149
+ const distanceToBottom = () => {
7150
+ const node = listRef.current;
7151
+ if (!node) return null;
7152
+ return node.scrollHeight - node.scrollTop - node.clientHeight;
7153
+ };
7099
7154
 
7100
7155
  /**
7101
7156
  * Deliberately a plain function, not a \`useCallback\` \u2014 it must read the
@@ -7103,14 +7158,10 @@ const MultiSelect = React.forwardRef(
7103
7158
  * handler would need a props ref (writing refs during render is banned).
7104
7159
  */
7105
7160
  const maybeLoadMore = () => {
7106
- const node = listRef.current;
7107
- if (!node) return;
7161
+ const distance = distanceToBottom();
7162
+ if (distance === null) return;
7108
7163
 
7109
- const isNearBottom =
7110
- node.scrollHeight - node.scrollTop - node.clientHeight <
7111
- SCROLL_END_THRESHOLD_PX;
7112
-
7113
- if (!isNearBottom) {
7164
+ if (distance >= SCROLL_END_THRESHOLD_PX) {
7114
7165
  // Only an explicit scroll away from the boundary re-arms the latch.
7115
7166
  isLatchedRef.current = false;
7116
7167
  return;
@@ -7122,25 +7173,71 @@ const MultiSelect = React.forwardRef(
7122
7173
  onScrollEnd();
7123
7174
  };
7124
7175
 
7176
+ /**
7177
+ * \`scroll\` fires far more often than the browser paints, so a fast flick
7178
+ * delivers a burst of events whose geometry is mid-flight. Coalescing to
7179
+ * one \`requestAnimationFrame\` per burst measures once per frame, after
7180
+ * layout has settled, and still sees the final resting position.
7181
+ */
7182
+ const handleListScroll = () => {
7183
+ if (scrollFrameRef.current !== null) return;
7184
+ scrollFrameRef.current = requestAnimationFrame(() => {
7185
+ scrollFrameRef.current = null;
7186
+ maybeLoadMore();
7187
+ });
7188
+ };
7189
+
7125
7190
  // A closed menu or a new search starts from a clean latch.
7126
7191
  React.useEffect(() => {
7127
7192
  isLatchedRef.current = false;
7128
7193
  }, [isOpen, searchQuery]);
7129
7194
 
7195
+ React.useEffect(
7196
+ () => () => {
7197
+ if (scrollFrameRef.current !== null) {
7198
+ cancelAnimationFrame(scrollFrameRef.current);
7199
+ }
7200
+ },
7201
+ []
7202
+ );
7203
+
7130
7204
  /**
7131
- * A page can arrive without making the list taller than its max-height
7132
- * (few results, or a short viewport). No further \`scroll\` event would ever
7133
- * fire, so pagination would stall silently \u2014 re-check whenever the rendered
7134
- * options or the fetch state change.
7205
+ * Re-measure once a page has landed. Content growth emits no \`scroll\`
7206
+ * event, so without this the latch set on the way down would never clear
7207
+ * and pagination would stall permanently after a fast flick to the bottom.
7208
+ * It also covers the case where the page does not make the list taller
7209
+ * than its max-height (few results, or a short viewport), where no further
7210
+ * \`scroll\` event would ever fire either.
7211
+ *
7212
+ * The latch is cleared only once the bottom is genuinely out of reach \u2014
7213
+ * while the user is still pinned at distance ~0, it stays armed and the
7214
+ * next page is chained explicitly, so inertia cannot fan out into
7215
+ * duplicate requests.
7135
7216
  */
7136
7217
  React.useEffect(() => {
7137
- if (!isOpen || !hasMore || loadingMore) return;
7138
- const node = listRef.current;
7139
- if (!node || node.scrollHeight > node.clientHeight) return;
7140
- if (isLatchedRef.current) return;
7141
- isLatchedRef.current = true;
7142
- onScrollEnd?.();
7143
- }, [isOpen, portalTarget, hasMore, loadingMore, options, onScrollEnd]);
7218
+ const pageJustLanded = wasLoadingMoreRef.current && !loadingMore;
7219
+ wasLoadingMoreRef.current = loadingMore;
7220
+ if (!isOpen || loadingMore) return;
7221
+
7222
+ const timeoutId = window.setTimeout(() => {
7223
+ const distance = distanceToBottom();
7224
+ if (distance === null) return;
7225
+
7226
+ if (distance >= SCROLL_END_THRESHOLD_PX) {
7227
+ isLatchedRef.current = false;
7228
+ return;
7229
+ }
7230
+ if (!hasMore || !onScrollEndRef.current) return;
7231
+ // Still latched and nothing landed: the scroll handler's request is
7232
+ // in flight (or the consumer never set \`loadingMore\`) \u2014 don't refire.
7233
+ if (isLatchedRef.current && !pageJustLanded) return;
7234
+
7235
+ isLatchedRef.current = true;
7236
+ onScrollEndRef.current();
7237
+ }, POST_FETCH_MEASURE_DELAY_MS);
7238
+
7239
+ return () => window.clearTimeout(timeoutId);
7240
+ }, [isOpen, portalTarget, hasMore, loadingMore, options.length]);
7144
7241
 
7145
7242
  const flatOptions = React.useMemo(
7146
7243
  () => flattenMultiSelectOptions(options),
@@ -7186,8 +7283,11 @@ const MultiSelect = React.forwardRef(
7186
7283
  // Determine aria-describedby
7187
7284
  const ariaDescribedBy = error ? errorId : helperText ? helperId : undefined;
7188
7285
 
7189
- // Filter options by search query
7286
+ // Filter options by search query. Skipped when \`searchQuery\` is
7287
+ // controlled \u2014 the caller owns filtering then (typically server-side, so
7288
+ // \`options\` is already the filtered slice for the current query).
7190
7289
  const filteredOptions = React.useMemo(() => {
7290
+ if (isSearchControlled) return flatOptions;
7191
7291
  if (!searchable || !searchQuery.trim()) return flatOptions;
7192
7292
  const q = searchQuery.toLowerCase();
7193
7293
  return flatOptions.filter((option) => {
@@ -7198,7 +7298,7 @@ const MultiSelect = React.forwardRef(
7198
7298
  (option.group?.toLowerCase().includes(q) ?? false)
7199
7299
  );
7200
7300
  });
7201
- }, [flatOptions, searchable, searchQuery]);
7301
+ }, [flatOptions, searchable, searchQuery, isSearchControlled]);
7202
7302
 
7203
7303
  type DisplayItem =
7204
7304
  | { type: "option"; option: MultiSelectOption }
@@ -7316,7 +7416,7 @@ const MultiSelect = React.forwardRef(
7316
7416
  return;
7317
7417
  }
7318
7418
  setIsOpen(false);
7319
- setSearchQuery("");
7419
+ updateSearchQuery("");
7320
7420
  };
7321
7421
 
7322
7422
  const timeoutId = window.setTimeout(() => {
@@ -7327,13 +7427,13 @@ const MultiSelect = React.forwardRef(
7327
7427
  window.clearTimeout(timeoutId);
7328
7428
  document.removeEventListener("mousedown", handleClickOutside);
7329
7429
  };
7330
- }, [isOpen, refs.floating]);
7430
+ }, [isOpen, refs.floating, updateSearchQuery]);
7331
7431
 
7332
7432
  // Handle keyboard navigation
7333
7433
  const handleKeyDown = (e: React.KeyboardEvent) => {
7334
7434
  if (e.key === "Escape" && closeOnEscape) {
7335
7435
  setIsOpen(false);
7336
- setSearchQuery("");
7436
+ updateSearchQuery("");
7337
7437
  } else if (e.key === "Enter" || e.key === " ") {
7338
7438
  if (!isOpen) {
7339
7439
  e.preventDefault();
@@ -7536,7 +7636,7 @@ const MultiSelect = React.forwardRef(
7536
7636
  type="text"
7537
7637
  placeholder={searchPlaceholder}
7538
7638
  value={searchQuery}
7539
- onChange={(e) => setSearchQuery(e.target.value)}
7639
+ onChange={(e) => updateSearchQuery(e.target.value)}
7540
7640
  className="w-full h-[42px] px-3 text-base text-semantic-text-primary border border-solid border-semantic-border-input rounded bg-semantic-bg-primary placeholder:text-semantic-text-placeholder focus:outline-none focus:border-semantic-border-input-focus/50"
7541
7641
  onClick={(e) => e.stopPropagation()}
7542
7642
  />
@@ -7572,7 +7672,7 @@ const MultiSelect = React.forwardRef(
7572
7672
  {/* Options */}
7573
7673
  <div
7574
7674
  ref={listRef}
7575
- onScroll={maybeLoadMore}
7675
+ onScroll={handleListScroll}
7576
7676
  className="overflow-auto overscroll-contain p-1"
7577
7677
  style={{
7578
7678
  maxHeight:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myoperator-mcp",
3
- "version": "0.2.375",
3
+ "version": "0.2.377",
4
4
  "description": "MCP server for myOperator UI components - enables AI assistants to access component metadata, examples, and design tokens",
5
5
  "type": "module",
6
6
  "bin": "./dist/index.js",