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.
- package/README.md +223 -223
- package/dist/index.js +920 -280
- 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
|
-
{/*
|
|
1534
|
-
<div
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1593
|
+
removeValue(val)
|
|
1625
1594
|
}}
|
|
1626
|
-
className="
|
|
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
|
-
<
|
|
1629
|
-
{option.label}
|
|
1598
|
+
<X className="size-2.5" />
|
|
1630
1599
|
</button>
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
|
1641
|
-
<span className="text-sm text-semantic-text-muted
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
3044
|
-
options:
|
|
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
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
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) =>
|
|
3429
|
+
.map((v) => flatOptions.find((o) => o.value === v)?.label)
|
|
3146
3430
|
.filter(Boolean) as string[];
|
|
3147
|
-
}, [selectedValues,
|
|
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
|
|
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
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
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-
|
|
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
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
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
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
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
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
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
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
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
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
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
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
{/*
|
|
3534
|
-
<div className="flex
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3966
|
+
<ChevronDown className="size-3" />
|
|
3552
3967
|
</button>
|
|
3553
3968
|
</div>
|
|
3554
3969
|
<span
|
|
3555
|
-
className="inline-flex items-center
|
|
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
|
-
|
|
5150
|
-
|
|
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
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": [],
|