myoperator-mcp 0.2.170 → 0.2.172

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 (3) hide show
  1. package/README.md +223 -223
  2. package/dist/index.js +920 -280
  3. package/package.json +42 -42
package/dist/index.js CHANGED
@@ -1412,6 +1412,11 @@ export interface CreatableMultiSelectProps
1412
1412
  state?: "default" | "error"
1413
1413
  /** Helper text shown below the trigger */
1414
1414
  helperText?: string
1415
+ /**
1416
+ * Shown inside the open dropdown (e.g. "Type to create a custom tone").
1417
+ * Pair with {@link maxItems} so users see guidance when no preset matches their typing.
1418
+ */
1419
+ createHintText?: string
1415
1420
  /** Max number of items that can be selected (default: unlimited) */
1416
1421
  maxItems?: number
1417
1422
  /** Max character length per item when typing/creating (default: unlimited) */
@@ -1441,6 +1446,7 @@ const CreatableMultiSelect = React.forwardRef(
1441
1446
  disabled = false,
1442
1447
  state = "default",
1443
1448
  helperText,
1449
+ createHintText,
1444
1450
  maxItems,
1445
1451
  maxLengthPerItem,
1446
1452
  sanitizeInput,
@@ -1518,134 +1524,214 @@ const CreatableMultiSelect = React.forwardRef(
1518
1524
  o.label.toLowerCase().includes(inputValue.trim().toLowerCase())
1519
1525
  )
1520
1526
  : availablePresets
1527
+
1528
+ const afterSanitizeDraft = sanitizeInput
1529
+ ? sanitizeInput(inputValue)
1530
+ : inputValue
1531
+ const trimmedDraft = afterSanitizeDraft.trim()
1532
+ const draftForCreate = trimmedDraft
1533
+ ? maxLengthPerItem != null
1534
+ ? trimmedDraft.slice(0, maxLengthPerItem)
1535
+ : trimmedDraft
1536
+ : ""
1537
+
1538
+ const canShowEnterAffordance =
1539
+ Boolean(draftForCreate) &&
1540
+ !value.includes(draftForCreate) &&
1541
+ (maxItems == null || value.length < maxItems)
1542
+
1543
+ const hasHintCopy = Boolean(createHintText) || maxItems != null
1544
+
1545
+ const showHintsRow =
1546
+ hasHintCopy || canShowEnterAffordance
1547
+
1521
1548
  const triggerState = isOpen
1522
1549
  ? state === "error"
1523
1550
  ? "focused-error"
1524
1551
  : "focused"
1525
1552
  : state
1526
1553
 
1554
+ /** Must match \`CreatableSelect\` hint-row \`<kbd>\` (Primary Role) \u2014 includes \`font-sans\` to override UA monospace on \`<kbd>\`. */
1555
+ const creatableHintEnterKbdClassName =
1556
+ "inline-flex items-center gap-0.5 rounded border border-solid border-semantic-border-layout bg-semantic-bg-ui px-1.5 py-0.5 font-sans text-[10px] font-medium text-semantic-text-muted"
1557
+
1527
1558
  return (
1528
1559
  <div
1529
1560
  ref={containerRef}
1530
1561
  className={cn("relative w-full", className)}
1531
1562
  {...props}
1532
1563
  >
1533
- {/* Trigger */}
1534
- <div
1535
- className={cn(
1536
- creatableMultiSelectTriggerVariants({ state: triggerState }),
1537
- disabled && "cursor-not-allowed opacity-50"
1538
- )}
1539
- onClick={() => {
1540
- if (disabled) return
1541
- setIsOpen(true)
1542
- inputRef.current?.focus()
1543
- }}
1544
- >
1545
- {/* Selected chips */}
1546
- {value.map((val) => {
1547
- const optLabel =
1548
- options.find((o) => o.value === val)?.label || val
1549
- return (
1550
- <span
1551
- key={val}
1552
- className="inline-flex items-center gap-2 bg-semantic-info-surface px-2 py-1 rounded text-sm text-semantic-text-primary whitespace-nowrap"
1553
- >
1554
- {optLabel}
1555
- <button
1556
- type="button"
1557
- onMouseDown={(e) => {
1558
- e.stopPropagation()
1559
- e.preventDefault()
1560
- removeValue(val)
1561
- }}
1562
- className="shrink-0 flex items-center justify-center text-semantic-text-muted hover:text-semantic-text-primary transition-colors"
1563
- aria-label={\`Remove \${optLabel}\`}
1564
- >
1565
- <X className="size-2.5" />
1566
- </button>
1567
- </span>
1568
- )
1569
- })}
1570
-
1571
- {/* Text input */}
1572
- <input
1573
- ref={inputRef}
1574
- type="text"
1575
- value={inputValue}
1576
- onChange={(e) => {
1577
- const raw = e.target.value
1578
- const sanitized = sanitizeInput ? sanitizeInput(raw) : raw
1579
- if (sanitizeInput) {
1580
- if (raw !== sanitized) onInvalidCharacters?.()
1581
- else onValidInput?.()
1582
- }
1583
- setInputValue(
1584
- maxLengthPerItem != null
1585
- ? sanitized.slice(0, maxLengthPerItem)
1586
- : sanitized
1587
- )
1588
- if (!isOpen) setIsOpen(true)
1589
- }}
1590
- maxLength={maxLengthPerItem}
1591
- onFocus={() => {
1592
- if (!disabled) setIsOpen(true)
1564
+ {/* Positioning context = trigger only so the dropdown aligns like CreatableSelect (counter sits below, not above the panel). */}
1565
+ <div className="relative w-full">
1566
+ {/* Trigger */}
1567
+ <div
1568
+ className={cn(
1569
+ creatableMultiSelectTriggerVariants({ state: triggerState }),
1570
+ disabled && "cursor-not-allowed opacity-50"
1571
+ )}
1572
+ onClick={() => {
1573
+ if (disabled) return
1574
+ setIsOpen(true)
1575
+ inputRef.current?.focus()
1593
1576
  }}
1594
- onKeyDown={handleKeyDown}
1595
- disabled={disabled}
1596
- placeholder={value.length === 0 ? placeholder : ""}
1597
- className="flex-1 min-w-[100px] text-base bg-transparent outline-none text-semantic-text-primary placeholder:text-semantic-text-muted"
1598
- role="combobox"
1599
- aria-expanded={isOpen}
1600
- aria-controls={listboxId}
1601
- aria-haspopup="listbox"
1602
- />
1603
-
1604
- {/* Chevron */}
1605
- {isOpen ? (
1606
- <ChevronRight className="size-5 text-semantic-text-muted shrink-0 ml-auto" />
1607
- ) : (
1608
- <ChevronDown className="size-5 text-semantic-text-muted shrink-0 ml-auto" />
1609
- )}
1610
- </div>
1611
-
1612
- {/* Dropdown panel */}
1613
- {isOpen && (
1614
- <div id={listboxId} role="listbox" className="absolute z-[9999] top-full mt-1 w-full bg-semantic-bg-primary border border-solid border-semantic-border-layout rounded shadow-md animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200">
1615
- {/* Preset option chips */}
1616
- {filteredPresets.length > 0 && (
1617
- <div className="px-2.5 py-2 flex flex-wrap gap-1.5">
1618
- {filteredPresets.map((option) => (
1577
+ >
1578
+ {/* Selected chips */}
1579
+ {value.map((val) => {
1580
+ const optLabel =
1581
+ options.find((o) => o.value === val)?.label || val
1582
+ return (
1583
+ <span
1584
+ key={val}
1585
+ className="inline-flex items-center gap-2 bg-semantic-info-surface px-2 py-1 rounded text-sm text-semantic-text-primary whitespace-nowrap"
1586
+ >
1587
+ {optLabel}
1619
1588
  <button
1620
- key={option.value}
1621
1589
  type="button"
1622
1590
  onMouseDown={(e) => {
1591
+ e.stopPropagation()
1623
1592
  e.preventDefault()
1624
- addValue(option.value)
1593
+ removeValue(val)
1625
1594
  }}
1626
- className="inline-flex items-center gap-1.5 bg-semantic-bg-ui px-2.5 py-1.5 rounded text-sm text-semantic-text-primary hover:bg-semantic-bg-hover transition-colors whitespace-nowrap"
1595
+ className="shrink-0 flex items-center justify-center text-semantic-text-muted hover:text-semantic-text-primary transition-colors"
1596
+ aria-label={\`Remove \${optLabel}\`}
1627
1597
  >
1628
- <Plus className="size-3 shrink-0 text-semantic-text-muted" />
1629
- {option.label}
1598
+ <X className="size-2.5" />
1630
1599
  </button>
1631
- ))}
1632
- </div>
1633
- )}
1600
+ </span>
1601
+ )
1602
+ })}
1603
+
1604
+ {/* Text input */}
1605
+ <input
1606
+ ref={inputRef}
1607
+ type="text"
1608
+ value={inputValue}
1609
+ onChange={(e) => {
1610
+ const raw = e.target.value
1611
+ const sanitized = sanitizeInput ? sanitizeInput(raw) : raw
1612
+ if (sanitizeInput) {
1613
+ if (raw !== sanitized) onInvalidCharacters?.()
1614
+ else onValidInput?.()
1615
+ }
1616
+ setInputValue(
1617
+ maxLengthPerItem != null
1618
+ ? sanitized.slice(0, maxLengthPerItem)
1619
+ : sanitized
1620
+ )
1621
+ if (!isOpen) setIsOpen(true)
1622
+ }}
1623
+ maxLength={maxLengthPerItem}
1624
+ onFocus={() => {
1625
+ if (!disabled) setIsOpen(true)
1626
+ }}
1627
+ onKeyDown={handleKeyDown}
1628
+ disabled={disabled}
1629
+ placeholder={value.length === 0 ? placeholder : ""}
1630
+ className="flex-1 min-w-[100px] text-base bg-transparent outline-none text-semantic-text-primary placeholder:text-semantic-text-muted"
1631
+ role="combobox"
1632
+ aria-expanded={isOpen}
1633
+ aria-controls={listboxId}
1634
+ aria-haspopup="listbox"
1635
+ />
1634
1636
 
1637
+ {/* Chevron */}
1638
+ {isOpen ? (
1639
+ <ChevronRight className="size-5 text-semantic-text-muted shrink-0 ml-auto" />
1640
+ ) : (
1641
+ <ChevronDown className="size-5 text-semantic-text-muted shrink-0 ml-auto" />
1642
+ )}
1635
1643
  </div>
1636
- )}
1644
+
1645
+ {/* Dropdown panel \u2014 top-full is bottom of trigger row only (matches Primary Role / CreatableSelect). */}
1646
+ {isOpen && (
1647
+ <div
1648
+ id={listboxId}
1649
+ role="listbox"
1650
+ className="absolute left-0 top-full z-[9999] mt-1 w-full rounded border border-solid border-semantic-border-layout bg-semantic-bg-primary shadow-md animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200"
1651
+ >
1652
+ {showHintsRow && (
1653
+ <div
1654
+ className={cn(
1655
+ "flex items-center justify-between gap-2 px-4 py-2",
1656
+ filteredPresets.length > 0 &&
1657
+ "border-b border-solid border-semantic-border-layout",
1658
+ !hasHintCopy && "justify-end"
1659
+ )}
1660
+ >
1661
+ {hasHintCopy ? (
1662
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
1663
+ {createHintText ? (
1664
+ <span className="text-sm text-semantic-text-muted">
1665
+ {createHintText}
1666
+ </span>
1667
+ ) : null}
1668
+ {maxItems != null ? (
1669
+ <span className="text-xs text-semantic-text-muted">
1670
+ Max selections allowed: {maxItems}
1671
+ </span>
1672
+ ) : null}
1673
+ </div>
1674
+ ) : null}
1675
+ <span
1676
+ role="button"
1677
+ tabIndex={canShowEnterAffordance ? 0 : -1}
1678
+ aria-label="Add using Enter key"
1679
+ aria-disabled={!canShowEnterAffordance}
1680
+ className={cn(
1681
+ "inline-flex shrink-0",
1682
+ !canShowEnterAffordance && "pointer-events-none"
1683
+ )}
1684
+ onMouseDown={(e) => {
1685
+ e.preventDefault()
1686
+ if (canShowEnterAffordance) addValue(inputValue)
1687
+ }}
1688
+ onKeyDown={(e) => {
1689
+ if (e.key !== "Enter" && e.key !== " ") return
1690
+ e.preventDefault()
1691
+ if (canShowEnterAffordance) addValue(inputValue)
1692
+ }}
1693
+ >
1694
+ <kbd className={creatableHintEnterKbdClassName}>
1695
+ Enter \u21B5
1696
+ </kbd>
1697
+ </span>
1698
+ </div>
1699
+ )}
1700
+
1701
+ {/* Preset option chips \u2014 pt-1 px-4 matches CreatableSelect listbox padding above first option */}
1702
+ {filteredPresets.length > 0 && (
1703
+ <div className="flex flex-wrap gap-1.5 px-4 pt-1 pb-2">
1704
+ {filteredPresets.map((option) => (
1705
+ <button
1706
+ key={option.value}
1707
+ type="button"
1708
+ onMouseDown={(e) => {
1709
+ e.preventDefault()
1710
+ addValue(option.value)
1711
+ }}
1712
+ className="inline-flex items-center gap-1.5 whitespace-nowrap rounded bg-semantic-bg-ui px-2.5 py-1.5 text-sm text-semantic-text-primary transition-colors hover:bg-semantic-bg-hover"
1713
+ >
1714
+ <Plus className="size-3 shrink-0 text-semantic-text-muted" />
1715
+ {option.label}
1716
+ </button>
1717
+ ))}
1718
+ </div>
1719
+ )}
1720
+ </div>
1721
+ )}
1722
+ </div>
1637
1723
 
1638
1724
  {/* Helper row below trigger: when maxLengthPerItem show dynamic hint + counter (Figma); else optional static helperText */}
1639
1725
  {maxLengthPerItem != null ? (
1640
- <div className="flex items-center justify-end gap-2 mt-1.5">
1641
- <span className="text-sm text-semantic-text-muted shrink-0">
1726
+ <div className="mt-1.5 flex items-center justify-end gap-2">
1727
+ <span className="shrink-0 text-sm text-semantic-text-muted">
1642
1728
  {inputValue.length}/{maxLengthPerItem}
1643
1729
  </span>
1644
1730
  </div>
1645
1731
  ) : (
1646
1732
  helperText &&
1647
1733
  !isOpen && (
1648
- <div className="flex items-center gap-1.5 mt-1.5">
1734
+ <div className="mt-1.5 flex items-center gap-1.5">
1649
1735
  <Info className="size-[18px] shrink-0 text-semantic-text-muted" />
1650
1736
  <p className="m-0 text-sm text-semantic-text-muted">
1651
1737
  {helperText}
@@ -1957,7 +2043,7 @@ const CreatableSelect = React.forwardRef(
1957
2043
  <span className="text-sm text-semantic-text-muted">
1958
2044
  {creatableHint}
1959
2045
  </span>
1960
- <kbd className="inline-flex items-center gap-0.5 rounded border border-solid border-semantic-border-layout bg-semantic-bg-ui px-1.5 py-0.5 text-[10px] text-semantic-text-muted font-medium">
2046
+ <kbd className="inline-flex items-center gap-0.5 rounded border border-solid border-semantic-border-layout bg-semantic-bg-ui px-1.5 py-0.5 font-sans text-[10px] font-medium text-semantic-text-muted">
1961
2047
  Enter \u21B5
1962
2048
  </kbd>
1963
2049
  </div>
@@ -2885,7 +2971,7 @@ const inputVariants = cva(
2885
2971
  default:
2886
2972
  "border border-solid border-semantic-border-input focus:outline-none focus:border-semantic-border-input-focus focus:shadow-[0_0_0_1px_rgba(43,188,202,0.15)]",
2887
2973
  error:
2888
- "border border-solid border-semantic-error-primary/40 focus:outline-none focus:border-semantic-error-primary focus:shadow-[0_0_0_1px_rgba(240,68,56,0.1)]",
2974
+ "border border-solid border-semantic-error-primary focus:outline-none focus:border-semantic-error-primary focus:shadow-[0_0_0_1px_rgba(240,68,56,0.12)]",
2889
2975
  },
2890
2976
  },
2891
2977
  defaultVariants: {
@@ -2984,9 +3070,114 @@ export { Input, inputVariants };
2984
3070
  `,
2985
3071
  "multi-select": `import * as React from "react";
2986
3072
  import { cva, type VariantProps } from "class-variance-authority";
2987
- import { Check, ChevronDown, X, Loader2 } from "lucide-react";
3073
+ import { Check, ChevronDown, CircleAlert, Loader2, X } from "lucide-react";
2988
3074
 
2989
3075
  import { cn } from "@/lib/utils";
3076
+ import { Checkbox } from "./checkbox";
3077
+ import {
3078
+ Tooltip,
3079
+ TooltipContent,
3080
+ TooltipProvider,
3081
+ TooltipTrigger,
3082
+ } from "./tooltip";
3083
+
3084
+ /**
3085
+ * Single selectable row (similar to legacy OptionType \u2014 different name).
3086
+ * Selection state is driven by \`value\` / \`onValueChange\`, not by a prop.
3087
+ */
3088
+ export interface MultiSelectChoice {
3089
+ value: string;
3090
+ label: string;
3091
+ /** Secondary line (e.g. \u201CAssigned to \u2026\u201D) \u2014 alias of \`secondaryText\` on \`MultiSelectOption\` */
3092
+ caption?: string;
3093
+ /** Disabled row \u2014 alias of \`disabled\` */
3094
+ isDisabled?: boolean;
3095
+ /** Tooltip on disabled row \u2014 alias of \`disabledTooltip\` */
3096
+ overlayMsg?: string;
3097
+ isDeleted?: boolean;
3098
+ isLast?: boolean;
3099
+ }
3100
+
3101
+ /**
3102
+ * Grouped options (similar to legacy GroupBase \u2014 different name).
3103
+ */
3104
+ export interface MultiSelectGroupedSection<
3105
+ T extends MultiSelectChoice = MultiSelectChoice,
3106
+ > {
3107
+ readonly label?: string;
3108
+ readonly options: readonly T[];
3109
+ }
3110
+
3111
+ /** Flat list or grouped sections */
3112
+ export type MultiSelectOptionInput =
3113
+ | MultiSelectOption[]
3114
+ | readonly MultiSelectGroupedSection[];
3115
+
3116
+ /**
3117
+ * Normalized option: supports both \`caption\` / \`isDisabled\` / \`overlayMsg\` and
3118
+ * \`secondaryText\` / \`disabled\` / \`disabledTooltip\`.
3119
+ */
3120
+ export interface MultiSelectOption extends MultiSelectChoice {
3121
+ secondaryText?: string;
3122
+ disabled?: boolean;
3123
+ disabledTooltip?: string;
3124
+ group?: string;
3125
+ }
3126
+
3127
+ /**
3128
+ * Use when typing **object literals** for fixtures, stories, or tests so TypeScript applies
3129
+ * [excess property checking](https://www.typescriptlang.org/docs/handbook/2/objects.html#excess-property-checks)
3130
+ * (catches typos like \`captoin\` and mistaken duplicate keys).
3131
+ *
3132
+ * This omits \`secondaryText\`; use \`caption\` for the secondary line \u2014 runtime code still accepts
3133
+ * both via {@link normalizeMultiSelectOption}. Widen to {@link MultiSelectOption} where a
3134
+ * consumer expects both aliases.
3135
+ */
3136
+ export type MultiSelectOptionAuthoring = Omit<MultiSelectOption, "secondaryText">;
3137
+
3138
+ function isGroupedSections(
3139
+ input: MultiSelectOptionInput
3140
+ ): input is readonly MultiSelectGroupedSection[] {
3141
+ if (!Array.isArray(input) || input.length === 0) return false;
3142
+ return input.every(
3143
+ (item) =>
3144
+ item !== null &&
3145
+ typeof item === "object" &&
3146
+ "options" in item &&
3147
+ Array.isArray((item as MultiSelectGroupedSection).options)
3148
+ );
3149
+ }
3150
+
3151
+ export function normalizeMultiSelectOption(
3152
+ o: MultiSelectOption | MultiSelectChoice
3153
+ ): MultiSelectOption {
3154
+ const merged = { ...(o as MultiSelectOption) };
3155
+ merged.disabled = merged.disabled ?? merged.isDisabled ?? false;
3156
+ merged.secondaryText = merged.secondaryText ?? merged.caption;
3157
+ merged.disabledTooltip = merged.disabledTooltip ?? merged.overlayMsg;
3158
+ return merged;
3159
+ }
3160
+
3161
+ export function flattenMultiSelectOptions(
3162
+ input: MultiSelectOptionInput
3163
+ ): MultiSelectOption[] {
3164
+ if (!input?.length) return [];
3165
+ if (isGroupedSections(input)) {
3166
+ const out: MultiSelectOption[] = [];
3167
+ for (const section of input) {
3168
+ const groupLabel = section.label ?? "";
3169
+ for (const raw of section.options) {
3170
+ const n = normalizeMultiSelectOption(raw as MultiSelectOption);
3171
+ out.push({
3172
+ ...n,
3173
+ group: n.group ?? groupLabel,
3174
+ });
3175
+ }
3176
+ }
3177
+ return out;
3178
+ }
3179
+ return (input as MultiSelectOption[]).map(normalizeMultiSelectOption);
3180
+ }
2990
3181
 
2991
3182
  /**
2992
3183
  * MultiSelect trigger variants matching TextField styling
@@ -3008,15 +3199,6 @@ const multiSelectTriggerVariants = cva(
3008
3199
  }
3009
3200
  );
3010
3201
 
3011
- export interface MultiSelectOption {
3012
- /** The value of the option */
3013
- value: string;
3014
- /** The display label of the option */
3015
- label: string;
3016
- /** Whether the option is disabled */
3017
- disabled?: boolean;
3018
- }
3019
-
3020
3202
  export interface MultiSelectProps extends VariantProps<
3021
3203
  typeof multiSelectTriggerVariants
3022
3204
  > {
@@ -3040,14 +3222,25 @@ export interface MultiSelectProps extends VariantProps<
3040
3222
  defaultValue?: string[];
3041
3223
  /** Callback when values change */
3042
3224
  onValueChange?: (value: string[]) => void;
3043
- /** Options to display */
3044
- options: MultiSelectOption[];
3225
+ /** Flat options or grouped sections (\`MultiSelectGroupedSection[]\`) */
3226
+ options: MultiSelectOptionInput;
3227
+ /**
3228
+ * When false (default), the list only closes on outside click (not Escape).
3229
+ * Set true to also close on Escape.
3230
+ */
3231
+ closeOnEscape?: boolean;
3045
3232
  /** Enable search/filter functionality */
3046
3233
  searchable?: boolean;
3047
3234
  /** Search placeholder text */
3048
3235
  searchPlaceholder?: string;
3049
3236
  /** Maximum selections allowed */
3050
3237
  maxSelections?: number;
3238
+ /**
3239
+ * When \`maxSelections\` is set, a footer shows the count (e.g. "3 / 5 selected").
3240
+ * Set to false to hide that footer while still enforcing the limit.
3241
+ * @default true
3242
+ */
3243
+ showSelectionFooter?: boolean;
3051
3244
  /** Additional class for wrapper */
3052
3245
  wrapperClassName?: string;
3053
3246
  /** Additional class for trigger */
@@ -3058,6 +3251,17 @@ export interface MultiSelectProps extends VariantProps<
3058
3251
  id?: string;
3059
3252
  /** Name attribute for form submission */
3060
3253
  name?: string;
3254
+ /**
3255
+ * simple: checkmark on the right (default).
3256
+ * detailed: checkbox + primary + optional secondary text (Figma / WhatsApp-style rows).
3257
+ */
3258
+ optionVariant?: "simple" | "detailed";
3259
+ /** When true, selected options appear first with a divider before the rest */
3260
+ separateSelectedWithDivider?: boolean;
3261
+ /** Show the clear-all control in the trigger (hidden in compact / Figma-style triggers) */
3262
+ showClearAll?: boolean;
3263
+ /** Vertical rule before the chevron (Figma-style trigger) */
3264
+ showSeparatorBeforeChevron?: boolean;
3061
3265
  }
3062
3266
 
3063
3267
  /**
@@ -3094,12 +3298,18 @@ const MultiSelect = React.forwardRef(
3094
3298
  searchable,
3095
3299
  searchPlaceholder = "Search...",
3096
3300
  maxSelections,
3301
+ showSelectionFooter = true,
3097
3302
  wrapperClassName,
3098
3303
  triggerClassName,
3099
3304
  labelClassName,
3100
3305
  state,
3101
3306
  id,
3102
3307
  name,
3308
+ optionVariant = "simple",
3309
+ separateSelectedWithDivider = false,
3310
+ showClearAll = true,
3311
+ showSeparatorBeforeChevron = false,
3312
+ closeOnEscape = false,
3103
3313
  }: MultiSelectProps,
3104
3314
  ref: React.Ref<HTMLButtonElement>
3105
3315
  ) => {
@@ -3114,6 +3324,11 @@ const MultiSelect = React.forwardRef(
3114
3324
  // Container ref for click outside detection
3115
3325
  const containerRef = React.useRef<HTMLDivElement>(null);
3116
3326
 
3327
+ const flatOptions = React.useMemo(
3328
+ () => flattenMultiSelectOptions(options),
3329
+ [options]
3330
+ );
3331
+
3117
3332
  // Determine if controlled
3118
3333
  const isControlled = value !== undefined;
3119
3334
  const selectedValues = isControlled ? value : internalValue;
@@ -3133,18 +3348,87 @@ const MultiSelect = React.forwardRef(
3133
3348
 
3134
3349
  // Filter options by search query
3135
3350
  const filteredOptions = React.useMemo(() => {
3136
- if (!searchable || !searchQuery) return options;
3137
- return options.filter((option) =>
3138
- option.label.toLowerCase().includes(searchQuery.toLowerCase())
3139
- );
3140
- }, [options, searchable, searchQuery]);
3351
+ if (!searchable || !searchQuery.trim()) return flatOptions;
3352
+ const q = searchQuery.toLowerCase();
3353
+ return flatOptions.filter((option) => {
3354
+ const secondary = option.secondaryText ?? "";
3355
+ return (
3356
+ option.label.toLowerCase().includes(q) ||
3357
+ secondary.toLowerCase().includes(q) ||
3358
+ (option.group?.toLowerCase().includes(q) ?? false)
3359
+ );
3360
+ });
3361
+ }, [flatOptions, searchable, searchQuery]);
3362
+
3363
+ type DisplayItem =
3364
+ | { type: "option"; option: MultiSelectOption }
3365
+ | { type: "divider" }
3366
+ | { type: "header"; label: string };
3367
+
3368
+ const hasGroupedOptions = flatOptions.some(
3369
+ (o) => o.group !== undefined && o.group !== ""
3370
+ );
3371
+
3372
+ const displayItems = React.useMemo((): DisplayItem[] => {
3373
+ const filtered = filteredOptions;
3374
+
3375
+ if (separateSelectedWithDivider) {
3376
+ const selected = filtered.filter((o) =>
3377
+ selectedValues.includes(o.value)
3378
+ );
3379
+ const unselected = filtered.filter(
3380
+ (o) => !selectedValues.includes(o.value)
3381
+ );
3382
+ const items: DisplayItem[] = selected.map((o) => ({
3383
+ type: "option",
3384
+ option: o,
3385
+ }));
3386
+ if (selected.length > 0 && unselected.length > 0) {
3387
+ items.push({ type: "divider" });
3388
+ }
3389
+ items.push(
3390
+ ...unselected.map((o) => ({ type: "option" as const, option: o }))
3391
+ );
3392
+ return items;
3393
+ }
3394
+
3395
+ if (hasGroupedOptions) {
3396
+ const order: string[] = [];
3397
+ const byGroup = new Map<string, MultiSelectOption[]>();
3398
+ for (const o of filtered) {
3399
+ const g = o.group ?? "";
3400
+ if (!byGroup.has(g)) {
3401
+ byGroup.set(g, []);
3402
+ order.push(g);
3403
+ }
3404
+ byGroup.get(g)!.push(o);
3405
+ }
3406
+ const items: DisplayItem[] = [];
3407
+ for (const g of order) {
3408
+ if (g) {
3409
+ items.push({ type: "header", label: g });
3410
+ }
3411
+ for (const o of byGroup.get(g)!) {
3412
+ items.push({ type: "option", option: o });
3413
+ }
3414
+ }
3415
+ return items;
3416
+ }
3417
+
3418
+ return filtered.map((o) => ({ type: "option" as const, option: o }));
3419
+ }, [
3420
+ filteredOptions,
3421
+ hasGroupedOptions,
3422
+ separateSelectedWithDivider,
3423
+ selectedValues,
3424
+ ]);
3141
3425
 
3142
3426
  // Get selected option labels
3143
3427
  const selectedLabels = React.useMemo(() => {
3144
3428
  return selectedValues
3145
- .map((v) => options.find((o) => o.value === v)?.label)
3429
+ .map((v) => flatOptions.find((o) => o.value === v)?.label)
3146
3430
  .filter(Boolean) as string[];
3147
- }, [selectedValues, options]);
3431
+ }, [selectedValues, flatOptions]);
3148
3432
 
3149
3433
  // Handle toggle selection
3150
3434
  const toggleOption = (optionValue: string) => {
@@ -3198,7 +3482,7 @@ const MultiSelect = React.forwardRef(
3198
3482
 
3199
3483
  // Handle keyboard navigation
3200
3484
  const handleKeyDown = (e: React.KeyboardEvent) => {
3201
- if (e.key === "Escape") {
3485
+ if (e.key === "Escape" && closeOnEscape) {
3202
3486
  setIsOpen(false);
3203
3487
  setSearchQuery("");
3204
3488
  } else if (e.key === "Enter" || e.key === " ") {
@@ -3212,7 +3496,7 @@ const MultiSelect = React.forwardRef(
3212
3496
  return (
3213
3497
  <div
3214
3498
  ref={containerRef}
3215
- className={cn("flex flex-col gap-1 relative", wrapperClassName)}
3499
+ className={cn("flex flex-col gap-1", wrapperClassName)}
3216
3500
  >
3217
3501
  {/* Label */}
3218
3502
  {label && (
@@ -3230,26 +3514,29 @@ const MultiSelect = React.forwardRef(
3230
3514
  </label>
3231
3515
  )}
3232
3516
 
3233
- {/* Trigger */}
3234
- <button
3235
- ref={ref}
3236
- id={selectId}
3237
- type="button"
3238
- role="combobox"
3239
- aria-expanded={isOpen}
3240
- aria-haspopup="listbox"
3241
- aria-controls={listboxId}
3242
- aria-invalid={!!error}
3243
- aria-describedby={ariaDescribedBy}
3244
- disabled={disabled || loading}
3245
- onClick={() => !disabled && !loading && setIsOpen(!isOpen)}
3246
- onKeyDown={handleKeyDown}
3247
- className={cn(
3248
- multiSelectTriggerVariants({ state: derivedState }),
3249
- "text-left gap-2",
3250
- triggerClassName
3251
- )}
3252
- >
3517
+ {/* Trigger + helper/error + listbox share one positioning context so the
3518
+ menu opens directly under the field (and under helper text when present). */}
3519
+ <div className="relative w-full min-w-0 flex flex-col gap-1">
3520
+ {/* Trigger */}
3521
+ <button
3522
+ ref={ref}
3523
+ id={selectId}
3524
+ type="button"
3525
+ role="combobox"
3526
+ aria-expanded={isOpen}
3527
+ aria-haspopup="listbox"
3528
+ aria-controls={listboxId}
3529
+ aria-invalid={!!error}
3530
+ aria-describedby={ariaDescribedBy}
3531
+ disabled={disabled || loading}
3532
+ onClick={() => !disabled && !loading && setIsOpen(!isOpen)}
3533
+ onKeyDown={handleKeyDown}
3534
+ className={cn(
3535
+ multiSelectTriggerVariants({ state: derivedState }),
3536
+ "text-left gap-2",
3537
+ triggerClassName
3538
+ )}
3539
+ >
3253
3540
  <div className="flex-1 flex flex-wrap gap-1">
3254
3541
  {selectedValues.length === 0 ? (
3255
3542
  <span className="text-semantic-text-placeholder">
@@ -3284,8 +3571,8 @@ const MultiSelect = React.forwardRef(
3284
3571
  ))
3285
3572
  )}
3286
3573
  </div>
3287
- <div className="flex items-center gap-1">
3288
- {selectedValues.length > 0 && (
3574
+ <div className="flex items-center gap-2 shrink-0">
3575
+ {showClearAll && selectedValues.length > 0 && (
3289
3576
  <span
3290
3577
  role="button"
3291
3578
  tabIndex={0}
@@ -3302,12 +3589,18 @@ const MultiSelect = React.forwardRef(
3302
3589
  <X className="size-4 text-semantic-text-muted" />
3303
3590
  </span>
3304
3591
  )}
3592
+ {showSeparatorBeforeChevron && (
3593
+ <div
3594
+ className="w-px h-5 self-center border-l border-solid border-semantic-border-layout shrink-0"
3595
+ aria-hidden
3596
+ />
3597
+ )}
3305
3598
  {loading ? (
3306
3599
  <Loader2 className="size-4 animate-spin text-semantic-text-muted" />
3307
3600
  ) : (
3308
3601
  <ChevronDown
3309
3602
  className={cn(
3310
- "size-4 text-semantic-text-muted transition-transform",
3603
+ "size-4 text-semantic-text-muted transition-transform shrink-0",
3311
3604
  isOpen && "rotate-180"
3312
3605
  )}
3313
3606
  />
@@ -3315,106 +3608,216 @@ const MultiSelect = React.forwardRef(
3315
3608
  </div>
3316
3609
  </button>
3317
3610
 
3318
- {/* Dropdown */}
3319
- {isOpen && (
3320
- <div
3321
- id={listboxId}
3322
- className={cn(
3323
- "absolute z-50 mt-1 w-full rounded bg-semantic-bg-primary border border-solid border-semantic-border-layout shadow-md",
3324
- "top-full"
3325
- )}
3326
- role="listbox"
3327
- aria-multiselectable="true"
3328
- >
3329
- {/* Search input */}
3330
- {searchable && (
3331
- <div className="p-2 border-b border-solid border-semantic-border-layout">
3332
- <input
3333
- type="text"
3334
- placeholder={searchPlaceholder}
3335
- value={searchQuery}
3336
- onChange={(e) => setSearchQuery(e.target.value)}
3337
- className="w-full h-8 px-3 text-sm 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"
3338
- onClick={(e) => e.stopPropagation()}
3339
- />
3340
- </div>
3341
- )}
3611
+ {/* Helper / error sits between trigger and dropdown (normal flow), matching Figma */}
3612
+ {(error || helperText) && (
3613
+ <div className="flex justify-between items-start gap-2">
3614
+ {error ? (
3615
+ <div
3616
+ id={errorId}
3617
+ role="alert"
3618
+ className="flex items-center gap-1.5 min-w-0"
3619
+ >
3620
+ <CircleAlert
3621
+ className="size-3.5 shrink-0 text-semantic-error-primary"
3622
+ aria-hidden
3623
+ />
3624
+ <span className="text-xs text-semantic-error-primary">
3625
+ {error}
3626
+ </span>
3627
+ </div>
3628
+ ) : helperText ? (
3629
+ <span
3630
+ id={helperId}
3631
+ className="text-xs text-semantic-text-muted"
3632
+ >
3633
+ {helperText}
3634
+ </span>
3635
+ ) : null}
3636
+ </div>
3637
+ )}
3342
3638
 
3343
- {/* Options */}
3344
- <div className="max-h-60 overflow-auto p-1">
3345
- {filteredOptions.length === 0 ? (
3346
- <div className="py-6 text-center text-sm text-semantic-text-muted">
3347
- No results found
3639
+ {/* Dropdown */}
3640
+ {isOpen && (
3641
+ <TooltipProvider delayDuration={200}>
3642
+ <div
3643
+ id={listboxId}
3644
+ className={cn(
3645
+ "absolute left-0 right-0 z-[100] mt-1 w-full rounded bg-semantic-bg-primary border border-solid border-semantic-border-layout shadow-md",
3646
+ "top-full"
3647
+ )}
3648
+ role="listbox"
3649
+ aria-multiselectable="true"
3650
+ >
3651
+ {/* Search input */}
3652
+ {searchable && (
3653
+ <div className="p-2 border-b border-solid border-semantic-border-layout">
3654
+ <input
3655
+ type="text"
3656
+ placeholder={searchPlaceholder}
3657
+ value={searchQuery}
3658
+ onChange={(e) => setSearchQuery(e.target.value)}
3659
+ className="w-full h-8 px-3 text-sm 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"
3660
+ onClick={(e) => e.stopPropagation()}
3661
+ />
3348
3662
  </div>
3349
- ) : (
3350
- filteredOptions.map((option) => {
3351
- const isSelected = selectedValues.includes(option.value);
3352
- const isDisabled =
3353
- option.disabled ||
3354
- (!isSelected &&
3663
+ )}
3664
+
3665
+ {/* Options */}
3666
+ <div className="max-h-60 overflow-auto p-1">
3667
+ {filteredOptions.length === 0 ? (
3668
+ <div className="py-6 text-center text-sm text-semantic-text-muted">
3669
+ No results found
3670
+ </div>
3671
+ ) : (
3672
+ displayItems.map((item, itemIndex) => {
3673
+ if (item.type === "divider") {
3674
+ return (
3675
+ <div
3676
+ key={\`divider-\${itemIndex}\`}
3677
+ role="separator"
3678
+ className="my-1 h-px bg-semantic-border-layout"
3679
+ />
3680
+ );
3681
+ }
3682
+ if (item.type === "header") {
3683
+ return (
3684
+ <div
3685
+ key={\`header-\${item.label}-\${itemIndex}\`}
3686
+ className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-semantic-text-muted"
3687
+ >
3688
+ {item.label}
3689
+ </div>
3690
+ );
3691
+ }
3692
+
3693
+ const option = item.option;
3694
+ const isSelected = selectedValues.includes(option.value);
3695
+ const isMaxedOut =
3696
+ !isSelected &&
3355
3697
  maxSelections !== undefined &&
3356
3698
  maxSelections > 0 &&
3357
- selectedValues.length >= maxSelections);
3358
-
3359
- return (
3360
- <button
3361
- key={option.value}
3362
- type="button"
3363
- role="option"
3364
- aria-selected={isSelected}
3365
- disabled={isDisabled}
3366
- onClick={() => !isDisabled && toggleOption(option.value)}
3367
- className={cn(
3368
- "relative flex w-full cursor-pointer select-none items-center rounded-sm py-2 pl-4 pr-8 text-base text-semantic-text-primary outline-none",
3699
+ selectedValues.length >= maxSelections;
3700
+ const isDisabled =
3701
+ Boolean(option.disabled) || isMaxedOut;
3702
+ const secondaryLine = option.secondaryText ?? option.caption;
3703
+
3704
+ const rowClass = cn(
3705
+ "relative flex w-full cursor-pointer select-none items-center rounded-sm text-left text-semantic-text-primary outline-none",
3706
+ optionVariant === "detailed"
3707
+ ? "gap-2 px-2 py-2 text-sm"
3708
+ : "py-2 pl-4 pr-8 text-base",
3709
+ !isSelected &&
3369
3710
  "hover:bg-semantic-bg-ui focus:bg-semantic-bg-ui",
3370
- isSelected && "bg-semantic-bg-ui",
3371
- isDisabled && "pointer-events-none opacity-50"
3372
- )}
3373
- >
3374
- <span className="absolute right-2 flex size-4 items-center justify-center">
3375
- {isSelected && (
3376
- <Check className="size-4 text-semantic-brand" />
3377
- )}
3378
- </span>
3379
- {option.label}
3380
- </button>
3381
- );
3382
- })
3383
- )}
3384
- </div>
3711
+ isDisabled && "opacity-50 cursor-not-allowed",
3712
+ option.isDeleted && "line-through opacity-70"
3713
+ );
3714
+
3715
+ const simpleRow = (
3716
+ <button
3717
+ type="button"
3718
+ role="option"
3719
+ aria-selected={isSelected}
3720
+ disabled={isDisabled}
3721
+ onClick={() =>
3722
+ !isDisabled && toggleOption(option.value)
3723
+ }
3724
+ className={rowClass}
3725
+ >
3726
+ <span className="absolute right-2 flex size-4 items-center justify-center">
3727
+ {isSelected && (
3728
+ <Check className="size-4 text-semantic-brand" />
3729
+ )}
3730
+ </span>
3731
+ {option.label}
3732
+ </button>
3733
+ );
3734
+
3735
+ const detailedRow = (
3736
+ <div
3737
+ role="option"
3738
+ tabIndex={isDisabled ? -1 : 0}
3739
+ aria-selected={isSelected}
3740
+ aria-disabled={isDisabled}
3741
+ data-disabled={isDisabled ? "" : undefined}
3742
+ onClick={() =>
3743
+ !isDisabled && toggleOption(option.value)
3744
+ }
3745
+ onKeyDown={(e) => {
3746
+ if (e.key === "Enter" || e.key === " ") {
3747
+ e.preventDefault();
3748
+ if (!isDisabled) toggleOption(option.value);
3749
+ }
3750
+ }}
3751
+ className={rowClass}
3752
+ >
3753
+ <Checkbox
3754
+ checked={isSelected}
3755
+ disabled={isDisabled}
3756
+ size="sm"
3757
+ className="pointer-events-none shrink-0"
3758
+ aria-hidden
3759
+ tabIndex={-1}
3760
+ />
3761
+ <span className="min-w-0 flex-1 truncate text-left">
3762
+ {option.label}
3763
+ </span>
3764
+ {secondaryLine ? (
3765
+ <span className="shrink-0 max-w-[55%] truncate text-right text-xs text-semantic-text-muted">
3766
+ {secondaryLine}
3767
+ </span>
3768
+ ) : null}
3769
+ </div>
3770
+ );
3771
+
3772
+ const overlayCopy =
3773
+ option.disabledTooltip ?? option.overlayMsg;
3774
+
3775
+ const withDisabledTooltip = (node: React.ReactElement) =>
3776
+ isDisabled && overlayCopy ? (
3777
+ <Tooltip key={option.value}>
3778
+ <TooltipTrigger asChild>
3779
+ <span className="block w-full cursor-default">
3780
+ {node}
3781
+ </span>
3782
+ </TooltipTrigger>
3783
+ <TooltipContent
3784
+ side="top"
3785
+ className="max-w-xs bg-semantic-primary text-semantic-text-inverted border-semantic-primary"
3786
+ >
3787
+ {overlayCopy}
3788
+ </TooltipContent>
3789
+ </Tooltip>
3790
+ ) : (
3791
+ <React.Fragment key={option.value}>
3792
+ {node}
3793
+ </React.Fragment>
3794
+ );
3795
+
3796
+ if (optionVariant === "detailed") {
3797
+ return withDisabledTooltip(detailedRow);
3798
+ }
3799
+
3800
+ return withDisabledTooltip(simpleRow);
3801
+ })
3802
+ )}
3803
+ </div>
3385
3804
 
3386
- {/* Footer with count */}
3387
- {maxSelections && (
3388
- <div className="p-2 border-t border-solid border-semantic-border-layout text-xs text-semantic-text-muted">
3389
- {selectedValues.length} / {maxSelections} selected
3805
+ {/* Footer with count */}
3806
+ {maxSelections && showSelectionFooter ? (
3807
+ <div className="p-2 border-t border-solid border-semantic-border-layout text-xs text-semantic-text-muted">
3808
+ {selectedValues.length} / {maxSelections} selected
3809
+ </div>
3810
+ ) : null}
3390
3811
  </div>
3391
- )}
3392
- </div>
3393
- )}
3812
+ </TooltipProvider>
3813
+ )}
3814
+ </div>
3394
3815
 
3395
3816
  {/* Hidden input for form submission */}
3396
3817
  {name &&
3397
3818
  selectedValues.map((v) => (
3398
3819
  <input key={v} type="hidden" name={name} value={v} />
3399
3820
  ))}
3400
-
3401
- {/* Helper text / Error message */}
3402
- {(error || helperText) && (
3403
- <div className="flex justify-between items-start gap-2">
3404
- {error ? (
3405
- <span
3406
- id={errorId}
3407
- className="text-xs text-semantic-error-primary"
3408
- >
3409
- {error}
3410
- </span>
3411
- ) : helperText ? (
3412
- <span id={helperId} className="text-xs text-semantic-text-muted">
3413
- {helperText}
3414
- </span>
3415
- ) : null}
3416
- </div>
3417
- )}
3418
3821
  </div>
3419
3822
  );
3420
3823
  }
@@ -3493,6 +3896,8 @@ const NumberStepField = React.forwardRef<HTMLDivElement, NumberStepFieldProps>(
3493
3896
  },
3494
3897
  ref
3495
3898
  ) => {
3899
+ const inputRef = React.useRef<HTMLInputElement>(null);
3900
+
3496
3901
  const handleChange = (raw: string) => {
3497
3902
  const parsed = parseOptionalInt(raw);
3498
3903
  if (parsed === null) return;
@@ -3507,6 +3912,13 @@ const NumberStepField = React.forwardRef<HTMLDivElement, NumberStepFieldProps>(
3507
3912
  onValueChange(clampInt(value - step, min, max));
3508
3913
  };
3509
3914
 
3915
+ /** Keeps focus on the input (not the button) so stepping after typing does not drop focus; also focuses the field when only the steppers are used so blur handlers run on leave. */
3916
+ const handleStepperPointerDown = (e: React.PointerEvent) => {
3917
+ if (disabled) return;
3918
+ e.preventDefault();
3919
+ inputRef.current?.focus();
3920
+ };
3921
+
3510
3922
  const atMax = value >= max;
3511
3923
  const atMin = value <= min;
3512
3924
 
@@ -3516,8 +3928,9 @@ const NumberStepField = React.forwardRef<HTMLDivElement, NumberStepFieldProps>(
3516
3928
  className={cn(numberStepFieldVariants({ layout: "default" }), className)}
3517
3929
  {...props}
3518
3930
  >
3519
- <div className="flex min-w-0 flex-1 items-stretch rounded-md border border-solid border-semantic-border-input overflow-hidden focus-within:border-semantic-border-input-focus focus-within:shadow-[0_0_0_1px_rgba(43,188,202,0.15)]">
3931
+ <div className="flex min-w-0 flex-1 items-center rounded-md border border-solid border-semantic-border-input overflow-hidden focus-within:border-semantic-border-input-focus focus-within:shadow-[0_0_0_1px_rgba(43,188,202,0.15)]">
3520
3932
  <Input
3933
+ ref={inputRef}
3521
3934
  type="number"
3522
3935
  min={min}
3523
3936
  max={max}
@@ -3530,29 +3943,31 @@ const NumberStepField = React.forwardRef<HTMLDivElement, NumberStepFieldProps>(
3530
3943
  hideNumberSpinners
3531
3944
  className="rounded-none border-0 h-10 min-w-0 flex-1 bg-semantic-bg-primary px-3 py-2 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none"
3532
3945
  />
3533
- {/* Inline steppers on the right inside the white field (before unit suffix) */}
3534
- <div className="flex h-10 w-7 shrink-0 flex-col bg-semantic-bg-primary pr-0.5">
3946
+ {/* Steppers \u2014 markup matches Advanced Settings \`ValidatedNumberSpinner\` (advanced-settings-card.tsx) */}
3947
+ <div className="flex flex-col items-center shrink-0 gap-0.5 bg-semantic-bg-primary pl-0.5 pr-1.5">
3535
3948
  <button
3536
3949
  type="button"
3537
3950
  disabled={disabled || atMax}
3951
+ onPointerDown={handleStepperPointerDown}
3538
3952
  onClick={stepUp}
3539
3953
  aria-label={incrementAriaLabel ?? "Increase value"}
3540
- className="flex min-h-0 flex-1 items-center justify-center text-semantic-text-muted hover:bg-semantic-bg-hover hover:text-semantic-text-primary disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-semantic-primary"
3954
+ className="flex items-center justify-center text-semantic-text-muted hover:text-semantic-text-primary transition-colors disabled:cursor-not-allowed disabled:opacity-40"
3541
3955
  >
3542
- <ChevronUp className="size-3.5" strokeWidth={2} />
3956
+ <ChevronUp className="size-3" />
3543
3957
  </button>
3544
3958
  <button
3545
3959
  type="button"
3546
3960
  disabled={disabled || atMin}
3961
+ onPointerDown={handleStepperPointerDown}
3547
3962
  onClick={stepDown}
3548
3963
  aria-label={decrementAriaLabel ?? "Decrease value"}
3549
- className="flex min-h-0 flex-1 items-center justify-center text-semantic-text-muted hover:bg-semantic-bg-hover hover:text-semantic-text-primary disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-semantic-primary"
3964
+ className="flex items-center justify-center text-semantic-text-muted hover:text-semantic-text-primary transition-colors disabled:cursor-not-allowed disabled:opacity-40"
3550
3965
  >
3551
- <ChevronDown className="size-3.5" strokeWidth={2} />
3966
+ <ChevronDown className="size-3" />
3552
3967
  </button>
3553
3968
  </div>
3554
3969
  <span
3555
- className="inline-flex items-center px-2.5 shrink-0 bg-semantic-bg-ui text-sm text-semantic-text-secondary"
3970
+ className="inline-flex h-10 items-center pl-3 pr-3.5 shrink-0 bg-semantic-bg-ui text-sm text-semantic-text-secondary"
3556
3971
  aria-hidden
3557
3972
  >
3558
3973
  {suffix}
@@ -5146,42 +5561,65 @@ function useUnlockBodyScroll() {
5146
5561
  }, []);
5147
5562
  }
5148
5563
 
5149
- const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>, ref: React.Ref<React.ElementRef<typeof SelectPrimitive.Content>>) => {
5150
- useUnlockBodyScroll();
5564
+ export type SelectContentProps = React.ComponentPropsWithoutRef<
5565
+ typeof SelectPrimitive.Content
5566
+ > & {
5567
+ /**
5568
+ * Fires on the scrollable list viewport when scrolling completes (\`scrollend\`).
5569
+ * Use with paginated option lists (e.g. load the next page when the user reaches the bottom).
5570
+ */
5571
+ onViewportScrollEnd?: (event: React.UIEvent<HTMLDivElement>) => void;
5572
+ };
5151
5573
 
5152
- return (
5153
- <SelectPrimitive.Portal>
5154
- <SelectPrimitive.Content
5155
- ref={ref}
5156
- className={cn(
5157
- "relative z-[9999] max-h-96 min-w-[8rem] overflow-hidden rounded bg-semantic-bg-primary border border-solid border-semantic-border-layout shadow-md",
5158
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
5159
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
5160
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
5161
- "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
5162
- "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
5163
- position === "popper" &&
5164
- "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
5165
- className
5166
- )}
5167
- position={position}
5168
- {...props}
5169
- >
5170
- <SelectScrollUpButton />
5171
- <SelectPrimitive.Viewport
5574
+ const SelectContent = React.forwardRef(
5575
+ (
5576
+ {
5577
+ className,
5578
+ children,
5579
+ position = "popper",
5580
+ onViewportScrollEnd,
5581
+ ...props
5582
+ }: SelectContentProps,
5583
+ ref: React.Ref<React.ElementRef<typeof SelectPrimitive.Content>>
5584
+ ) => {
5585
+ useUnlockBodyScroll();
5586
+
5587
+ return (
5588
+ <SelectPrimitive.Portal>
5589
+ <SelectPrimitive.Content
5590
+ ref={ref}
5172
5591
  className={cn(
5173
- "p-1",
5592
+ "relative z-[9999] max-h-96 min-w-[8rem] overflow-hidden rounded bg-semantic-bg-primary border border-solid border-semantic-border-layout shadow-md",
5593
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
5594
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
5595
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
5596
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
5597
+ "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
5174
5598
  position === "popper" &&
5175
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
5599
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
5600
+ className
5176
5601
  )}
5602
+ position={position}
5603
+ {...props}
5177
5604
  >
5178
- {children}
5179
- </SelectPrimitive.Viewport>
5180
- <SelectScrollDownButton />
5181
- </SelectPrimitive.Content>
5182
- </SelectPrimitive.Portal>
5183
- );
5184
- });
5605
+ <SelectScrollUpButton />
5606
+ <SelectPrimitive.Viewport
5607
+ data-select-viewport=""
5608
+ className={cn(
5609
+ "p-1",
5610
+ position === "popper" &&
5611
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
5612
+ )}
5613
+ onScrollEnd={onViewportScrollEnd}
5614
+ >
5615
+ {children}
5616
+ </SelectPrimitive.Viewport>
5617
+ <SelectScrollDownButton />
5618
+ </SelectPrimitive.Content>
5619
+ </SelectPrimitive.Portal>
5620
+ );
5621
+ }
5622
+ );
5185
5623
  SelectContent.displayName = SelectPrimitive.Content.displayName;
5186
5624
 
5187
5625
  const SelectLabel = React.forwardRef(({ className, ...props }: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>, ref: React.Ref<React.ElementRef<typeof SelectPrimitive.Label>>) => (
@@ -6520,6 +6958,7 @@ TextField.displayName = "TextField";
6520
6958
  export { TextField, textFieldContainerVariants, textFieldInputVariants };
6521
6959
  `,
6522
6960
  "textarea": `import * as React from "react";
6961
+ import { CircleAlert } from "lucide-react";
6523
6962
  import { cva, type VariantProps } from "class-variance-authority";
6524
6963
 
6525
6964
  import { cn } from "@/lib/utils";
@@ -6535,7 +6974,7 @@ const textareaVariants = cva(
6535
6974
  default:
6536
6975
  "border border-solid border-semantic-border-input focus:outline-none focus:border-semantic-border-input-focus focus:shadow-[0_0_0_1px_rgba(43,188,202,0.15)]",
6537
6976
  error:
6538
- "border border-solid border-semantic-error-primary/40 focus:outline-none focus:border-semantic-error-primary focus:shadow-[0_0_0_1px_rgba(240,68,56,0.1)]",
6977
+ "border border-solid border-semantic-error-primary focus:outline-none focus:border-semantic-error-primary focus:shadow-[0_0_0_1px_rgba(240,68,56,0.12)]",
6539
6978
  },
6540
6979
  size: {
6541
6980
  default: "px-4 py-2.5 text-base",
@@ -6556,6 +6995,8 @@ const textareaVariants = cva(
6556
6995
  * \`\`\`tsx
6557
6996
  * <Textarea label="Description" placeholder="Enter description" />
6558
6997
  * <Textarea label="Notes" error="Too short" showCount maxLength={500} />
6998
+ * <Textarea label="Notes" showCount maxLength={5000} enforceMaxLength={false} error={overflowMsg} />
6999
+ * <Textarea label="Message" error="Invalid characters not allowed." errorIcon />
6559
7000
  * <Textarea label="JSON" rows={8} resize="vertical" />
6560
7001
  * \`\`\`
6561
7002
  */
@@ -6572,8 +7013,17 @@ export interface TextareaProps
6572
7013
  helperText?: string;
6573
7014
  /** Error message \u2014 shows error state with red styling */
6574
7015
  error?: string;
7016
+ /**
7017
+ * When true and \`error\` is set, shows a leading error icon with the message (field-level validation pattern).
7018
+ */
7019
+ errorIcon?: boolean;
6575
7020
  /** Shows character count when maxLength is set */
6576
7021
  showCount?: boolean;
7022
+ /**
7023
+ * When true (default), \`maxLength\` is applied to the native textarea (hard limit).
7024
+ * When false, the limit is only used for \`showCount\` / styling \u2014 pair with \`error\` or parent validation for soft limits.
7025
+ */
7026
+ enforceMaxLength?: boolean;
6577
7027
  /** Controls CSS resize behavior. Defaults to "none" */
6578
7028
  resize?: "none" | "vertical" | "horizontal" | "both";
6579
7029
  /** Additional class for the wrapper container */
@@ -6594,7 +7044,9 @@ const Textarea = React.forwardRef(
6594
7044
  required,
6595
7045
  helperText,
6596
7046
  error,
7047
+ errorIcon = false,
6597
7048
  showCount,
7049
+ enforceMaxLength = true,
6598
7050
  resize = "none",
6599
7051
  maxLength,
6600
7052
  rows = 4,
@@ -6675,7 +7127,10 @@ const Textarea = React.forwardRef(
6675
7127
  resizeClasses[resize]
6676
7128
  )}
6677
7129
  disabled={disabled}
6678
- maxLength={maxLength}
7130
+ required={required}
7131
+ maxLength={
7132
+ enforceMaxLength !== false ? maxLength : undefined
7133
+ }
6679
7134
  value={isControlled ? value : undefined}
6680
7135
  defaultValue={!isControlled ? defaultValue : undefined}
6681
7136
  onChange={handleChange}
@@ -6688,12 +7143,28 @@ const Textarea = React.forwardRef(
6688
7143
  {(error || helperText || (showCount && maxLength)) && (
6689
7144
  <div className="flex justify-between items-start gap-2">
6690
7145
  {error ? (
6691
- <span
6692
- id={errorId}
6693
- className="text-sm text-semantic-error-primary"
6694
- >
6695
- {error}
6696
- </span>
7146
+ errorIcon ? (
7147
+ <div
7148
+ id={errorId}
7149
+ role="alert"
7150
+ className="flex items-center gap-1.5 min-w-0"
7151
+ >
7152
+ <CircleAlert
7153
+ className="size-3.5 shrink-0 text-semantic-error-primary"
7154
+ aria-hidden
7155
+ />
7156
+ <span className="text-sm text-semantic-error-primary">
7157
+ {error}
7158
+ </span>
7159
+ </div>
7160
+ ) : (
7161
+ <span
7162
+ id={errorId}
7163
+ className="text-sm text-semantic-error-primary"
7164
+ >
7165
+ {error}
7166
+ </span>
7167
+ )
6697
7168
  ) : helperText ? (
6698
7169
  <span id={helperId} className="text-sm text-semantic-text-muted">
6699
7170
  {helperText}
@@ -7199,9 +7670,177 @@ import { cn } from "@/lib/utils";
7199
7670
 
7200
7671
  const TooltipProvider = TooltipPrimitive.Provider;
7201
7672
 
7202
- const Tooltip = TooltipPrimitive.Root;
7673
+ /** True when the primary input cannot hover (touch / most phones & tablets). Desktop hover stays false. */
7674
+ function usePrefersTapTooltipInteraction() {
7675
+ const [tapMode, setTapMode] = React.useState(() => {
7676
+ if (typeof window === "undefined" || !window.matchMedia) {
7677
+ return false;
7678
+ }
7679
+ return window.matchMedia("(hover: none)").matches;
7680
+ });
7203
7681
 
7204
- const TooltipTrigger = TooltipPrimitive.Trigger;
7682
+ React.useEffect(() => {
7683
+ if (typeof window === "undefined" || !window.matchMedia) {
7684
+ return;
7685
+ }
7686
+ const mq = window.matchMedia("(hover: none)");
7687
+ const sync = () => setTapMode(mq.matches);
7688
+ sync();
7689
+ mq.addEventListener("change", sync);
7690
+ return () => mq.removeEventListener("change", sync);
7691
+ }, []);
7692
+
7693
+ return tapMode;
7694
+ }
7695
+
7696
+ type TooltipFieldContextValue = {
7697
+ tapMode: boolean;
7698
+ open: boolean;
7699
+ setOpen: (next: boolean | ((prev: boolean) => boolean)) => void;
7700
+ isControlled: boolean;
7701
+ /** True after pointerdown on touch; Radix may fire onOpenChange(true) from focus before click \u2014 ignore that open. */
7702
+ suppressFocusOpenRef: React.MutableRefObject<boolean>;
7703
+ };
7704
+
7705
+ const TooltipFieldContext = React.createContext<TooltipFieldContextValue | null>(null);
7706
+
7707
+ function useTooltipFieldContext() {
7708
+ const ctx = React.useContext(TooltipFieldContext);
7709
+ if (!ctx) {
7710
+ throw new Error("TooltipTrigger must be used within Tooltip");
7711
+ }
7712
+ return ctx;
7713
+ }
7714
+
7715
+ function composeEventHandlers<E extends React.SyntheticEvent>(
7716
+ original: React.EventHandler<E> | undefined,
7717
+ next: React.EventHandler<E> | undefined,
7718
+ ) {
7719
+ return (event: E) => {
7720
+ original?.(event);
7721
+ if (!event.defaultPrevented) {
7722
+ next?.(event);
7723
+ }
7724
+ };
7725
+ }
7726
+
7727
+ const Tooltip = (props: React.ComponentProps<typeof TooltipPrimitive.Root>) => {
7728
+ const { open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, delayDuration: delayDurationProp } = props;
7729
+ const tapMode = usePrefersTapTooltipInteraction();
7730
+ const isControlled = openProp !== undefined;
7731
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(() => defaultOpen ?? false);
7732
+
7733
+ const suppressFocusOpenRef = React.useRef(false);
7734
+
7735
+ const handleOpenChange = React.useCallback(
7736
+ (next: boolean) => {
7737
+ if (!isControlled) {
7738
+ if (tapMode && next === true && suppressFocusOpenRef.current) {
7739
+ return;
7740
+ }
7741
+ setUncontrolledOpen(next);
7742
+ }
7743
+ onOpenChangeProp?.(next);
7744
+ },
7745
+ [isControlled, onOpenChangeProp, tapMode],
7746
+ );
7747
+
7748
+ const openSnapshot = isControlled ? openProp! : uncontrolledOpen;
7749
+
7750
+ const setOpen = React.useCallback(
7751
+ (next: boolean | ((prev: boolean) => boolean)) => {
7752
+ if (isControlled) {
7753
+ const value = typeof next === "function" ? next(openProp!) : next;
7754
+ onOpenChangeProp?.(value);
7755
+ } else {
7756
+ setUncontrolledOpen(next);
7757
+ }
7758
+ },
7759
+ [isControlled, openProp, onOpenChangeProp],
7760
+ );
7761
+
7762
+ const rootProps: React.ComponentProps<typeof TooltipPrimitive.Root> = (() => {
7763
+ if (isControlled) {
7764
+ return props;
7765
+ }
7766
+ if (tapMode) {
7767
+ return {
7768
+ ...props,
7769
+ defaultOpen: undefined,
7770
+ open: uncontrolledOpen,
7771
+ onOpenChange: handleOpenChange,
7772
+ delayDuration: delayDurationProp ?? 0,
7773
+ };
7774
+ }
7775
+ return {
7776
+ ...props,
7777
+ onOpenChange: handleOpenChange,
7778
+ };
7779
+ })();
7780
+
7781
+ const contextValue: TooltipFieldContextValue = {
7782
+ tapMode,
7783
+ open: openSnapshot,
7784
+ setOpen,
7785
+ isControlled,
7786
+ suppressFocusOpenRef,
7787
+ };
7788
+
7789
+ return (
7790
+ <TooltipFieldContext.Provider value={contextValue}>
7791
+ <TooltipPrimitive.Root {...rootProps} />
7792
+ </TooltipFieldContext.Provider>
7793
+ );
7794
+ };
7795
+ Tooltip.displayName = TooltipPrimitive.Root.displayName;
7796
+
7797
+ const TooltipTrigger = React.forwardRef<
7798
+ React.ElementRef<typeof TooltipPrimitive.Trigger>,
7799
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
7800
+ >(({ onPointerDown, onClick, ...props }, ref) => {
7801
+ const { tapMode, open, setOpen, isControlled, suppressFocusOpenRef } = useTooltipFieldContext();
7802
+
7803
+ const onPointerDownForTap = React.useCallback(
7804
+ (e: React.PointerEvent<HTMLButtonElement>) => {
7805
+ if (tapMode) {
7806
+ suppressFocusOpenRef.current = true;
7807
+ }
7808
+ if (tapMode && open) {
7809
+ e.preventDefault();
7810
+ }
7811
+ },
7812
+ [tapMode, open, suppressFocusOpenRef],
7813
+ );
7814
+
7815
+ const onClickForTap = React.useCallback(
7816
+ (e: React.MouseEvent<HTMLButtonElement>) => {
7817
+ if (!tapMode) {
7818
+ return;
7819
+ }
7820
+ e.preventDefault();
7821
+ if (isControlled) {
7822
+ setOpen(!open);
7823
+ } else {
7824
+ setOpen((o) => !o);
7825
+ }
7826
+ suppressFocusOpenRef.current = false;
7827
+ },
7828
+ [tapMode, isControlled, open, setOpen, suppressFocusOpenRef],
7829
+ );
7830
+
7831
+ /* Event-handler-only ref writes; rule misfires on composeEventHandlers + useCallback. */
7832
+ /* eslint-disable react-hooks/refs -- suppressFocusOpenRef */
7833
+ return (
7834
+ <TooltipPrimitive.Trigger
7835
+ ref={ref}
7836
+ {...props}
7837
+ onPointerDown={composeEventHandlers(onPointerDown, onPointerDownForTap)}
7838
+ onClick={composeEventHandlers(onClick, onClickForTap)}
7839
+ />
7840
+ );
7841
+ /* eslint-enable react-hooks/refs */
7842
+ });
7843
+ TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
7205
7844
 
7206
7845
  const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>, ref: React.Ref<React.ElementRef<typeof TooltipPrimitive.Content>>) => (
7207
7846
  <TooltipPrimitive.Portal>
@@ -8773,7 +9412,8 @@ var componentMetadata = {
8773
9412
  "dependencies": [
8774
9413
  "class-variance-authority",
8775
9414
  "clsx",
8776
- "tailwind-merge"
9415
+ "tailwind-merge",
9416
+ "lucide-react"
8777
9417
  ],
8778
9418
  "props": [],
8779
9419
  "variants": [],