myoperator-ui 0.0.67 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +926 -3
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3014,6 +3014,908 @@ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
3014
3014
  Toggle.displayName = "Toggle"
3015
3015
 
3016
3016
  export { Toggle, toggleVariants }
3017
+ `, prefix)
3018
+ }
3019
+ ]
3020
+ },
3021
+ "event-selector": {
3022
+ name: "event-selector",
3023
+ description: "A component for selecting webhook events with groups, categories, and tri-state checkboxes",
3024
+ dependencies: [
3025
+ "clsx",
3026
+ "tailwind-merge"
3027
+ ],
3028
+ internalDependencies: [
3029
+ "checkbox",
3030
+ "collapsible"
3031
+ ],
3032
+ isMultiFile: true,
3033
+ directory: "event-selector",
3034
+ mainFile: "event-selector.tsx",
3035
+ files: [
3036
+ {
3037
+ name: "event-selector.tsx",
3038
+ content: prefixTailwindClasses(`import * as React from "react"
3039
+ import { cn } from "../../../lib/utils"
3040
+ import { EventGroupComponent } from "./event-group"
3041
+ import type { EventSelectorProps, EventCategory, EventGroup } from "./types"
3042
+
3043
+ /**
3044
+ * EventSelector - A component for selecting webhook events
3045
+ *
3046
+ * Install via CLI:
3047
+ * \`\`\`bash
3048
+ * npx myoperator-ui add event-selector
3049
+ * \`\`\`
3050
+ *
3051
+ * Or import directly from npm:
3052
+ * \`\`\`tsx
3053
+ * import { EventSelector } from "@myoperator/ui"
3054
+ * \`\`\`
3055
+ *
3056
+ * @example
3057
+ * \`\`\`tsx
3058
+ * <EventSelector
3059
+ * events={events}
3060
+ * groups={groups}
3061
+ * selectedEvents={selected}
3062
+ * onSelectionChange={setSelected}
3063
+ * />
3064
+ * \`\`\`
3065
+ */
3066
+ export const EventSelector = React.forwardRef<HTMLDivElement, EventSelectorProps>(
3067
+ (
3068
+ {
3069
+ events,
3070
+ groups,
3071
+ categories,
3072
+ selectedEvents: controlledSelected,
3073
+ onSelectionChange,
3074
+ defaultSelectedEvents = [],
3075
+ title = "Events",
3076
+ description = "Select which events should trigger this webhook",
3077
+ emptyGroupMessage,
3078
+ renderEmptyGroup,
3079
+ className,
3080
+ ...props
3081
+ },
3082
+ ref
3083
+ ) => {
3084
+ // Controlled vs uncontrolled state
3085
+ const [internalSelected, setInternalSelected] = React.useState<string[]>(
3086
+ defaultSelectedEvents
3087
+ )
3088
+
3089
+ const isControlled = controlledSelected !== undefined
3090
+ const selectedEvents = isControlled ? controlledSelected : internalSelected
3091
+
3092
+ const handleSelectionChange = React.useCallback(
3093
+ (newSelection: string[]) => {
3094
+ if (!isControlled) {
3095
+ setInternalSelected(newSelection)
3096
+ }
3097
+ onSelectionChange?.(newSelection)
3098
+ },
3099
+ [isControlled, onSelectionChange]
3100
+ )
3101
+
3102
+ // Get events for a specific group
3103
+ const getEventsForGroup = (groupId: string) => {
3104
+ return events.filter((event) => event.group === groupId)
3105
+ }
3106
+
3107
+ // Get groups for a specific category
3108
+ const getGroupsForCategory = (category: EventCategory): EventGroup[] => {
3109
+ return category.groups
3110
+ .map((groupId) => groups.find((g) => g.id === groupId))
3111
+ .filter((g): g is EventGroup => g !== undefined)
3112
+ }
3113
+
3114
+ // Calculate total selected count
3115
+ const totalSelected = selectedEvents.length
3116
+
3117
+ // Render groups without categories
3118
+ const renderGroups = (groupsToRender: EventGroup[]) => {
3119
+ return groupsToRender.map((group) => (
3120
+ <EventGroupComponent
3121
+ key={group.id}
3122
+ group={group}
3123
+ events={getEventsForGroup(group.id)}
3124
+ selectedEvents={selectedEvents}
3125
+ onSelectionChange={handleSelectionChange}
3126
+ emptyGroupMessage={emptyGroupMessage}
3127
+ renderEmptyGroup={renderEmptyGroup}
3128
+ />
3129
+ ))
3130
+ }
3131
+
3132
+ // Render categories with nested groups
3133
+ const renderCategories = () => {
3134
+ // Ensure categories is an array before using array methods
3135
+ if (!categories || !Array.isArray(categories) || categories.length === 0) {
3136
+ return renderGroups(groups)
3137
+ }
3138
+
3139
+ // Get groups that belong to categories
3140
+ const groupsInCategories = new Set(categories.flatMap((c) => c.groups))
3141
+
3142
+ // Get orphan groups (not in any category)
3143
+ const orphanGroups = groups.filter((g) => !groupsInCategories.has(g.id))
3144
+
3145
+ return (
3146
+ <>
3147
+ {categories.map((category) => {
3148
+ const categoryGroups = getGroupsForCategory(category)
3149
+ const categoryEventIds = categoryGroups.flatMap((g) =>
3150
+ getEventsForGroup(g.id).map((e) => e.id)
3151
+ )
3152
+ const selectedInCategory = categoryEventIds.filter((id) =>
3153
+ selectedEvents.includes(id)
3154
+ )
3155
+
3156
+ return (
3157
+ <div
3158
+ key={category.id}
3159
+ className="border border-[#E5E7EB] rounded-lg overflow-hidden"
3160
+ >
3161
+ {/* Category Header - no checkbox, just label */}
3162
+ <div className="flex items-center justify-between p-4 bg-white border-b border-[#E5E7EB]">
3163
+ <div className="flex items-center gap-3">
3164
+ {category.icon && (
3165
+ <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-[#F3F4F6]">
3166
+ {category.icon}
3167
+ </div>
3168
+ )}
3169
+ <span className="font-medium text-[#333333]">
3170
+ {category.name}
3171
+ </span>
3172
+ </div>
3173
+ {selectedInCategory.length > 0 && (
3174
+ <span className="text-sm text-[#6B7280]">
3175
+ {selectedInCategory.length} Selected
3176
+ </span>
3177
+ )}
3178
+ </div>
3179
+ {/* Category Groups */}
3180
+ <div className="p-4 space-y-3 bg-[#F9FAFB]">
3181
+ {renderGroups(categoryGroups)}
3182
+ </div>
3183
+ </div>
3184
+ )
3185
+ })}
3186
+ {/* Render orphan groups outside categories */}
3187
+ {orphanGroups.length > 0 && (
3188
+ <div className="space-y-3">{renderGroups(orphanGroups)}</div>
3189
+ )}
3190
+ </>
3191
+ )
3192
+ }
3193
+
3194
+ return (
3195
+ <div
3196
+ ref={ref}
3197
+ className={cn("w-full", className)}
3198
+ {...props}
3199
+ >
3200
+ {/* Header */}
3201
+ <div className="flex items-start justify-between mb-4">
3202
+ <div>
3203
+ <h3 className="text-base font-semibold text-[#333333]">{title}</h3>
3204
+ {description && (
3205
+ <p className="text-sm text-[#6B7280] mt-1">{description}</p>
3206
+ )}
3207
+ </div>
3208
+ <span className="text-sm font-medium text-[#333333]">
3209
+ {totalSelected} Selected
3210
+ </span>
3211
+ </div>
3212
+
3213
+ {/* Groups */}
3214
+ <div className="space-y-3">{renderCategories()}</div>
3215
+ </div>
3216
+ )
3217
+ }
3218
+ )
3219
+ EventSelector.displayName = "EventSelector"
3220
+ `, prefix)
3221
+ },
3222
+ {
3223
+ name: "event-group.tsx",
3224
+ content: prefixTailwindClasses(`import * as React from "react"
3225
+ import { cn } from "../../../lib/utils"
3226
+ import { Checkbox, type CheckedState } from "../checkbox"
3227
+ import {
3228
+ Collapsible,
3229
+ CollapsibleItem,
3230
+ CollapsibleTrigger,
3231
+ CollapsibleContent,
3232
+ } from "../collapsible"
3233
+ import { EventItemComponent } from "./event-item"
3234
+ import type { EventGroupComponentProps } from "./types"
3235
+
3236
+ /**
3237
+ * Event group with collapsible section and group-level checkbox
3238
+ */
3239
+ export const EventGroupComponent = React.forwardRef<
3240
+ HTMLDivElement,
3241
+ EventGroupComponentProps & React.HTMLAttributes<HTMLDivElement>
3242
+ >(
3243
+ (
3244
+ {
3245
+ group,
3246
+ events,
3247
+ selectedEvents,
3248
+ onSelectionChange,
3249
+ emptyGroupMessage = "No events available",
3250
+ renderEmptyGroup,
3251
+ className,
3252
+ ...props
3253
+ },
3254
+ ref
3255
+ ) => {
3256
+ // Calculate selection state for this group
3257
+ const groupEventIds = events.map((e) => e.id)
3258
+ const selectedInGroup = groupEventIds.filter((id) =>
3259
+ selectedEvents.includes(id)
3260
+ )
3261
+ const allSelected = groupEventIds.length > 0 && selectedInGroup.length === groupEventIds.length
3262
+ const someSelected = selectedInGroup.length > 0 && selectedInGroup.length < groupEventIds.length
3263
+
3264
+ const checkboxState: CheckedState = allSelected
3265
+ ? true
3266
+ : someSelected
3267
+ ? "indeterminate"
3268
+ : false
3269
+
3270
+ const selectedCount = selectedInGroup.length
3271
+
3272
+ // Handle group checkbox click
3273
+ const handleGroupCheckbox = () => {
3274
+ if (allSelected) {
3275
+ // Deselect all events in this group
3276
+ onSelectionChange(selectedEvents.filter((id) => !groupEventIds.includes(id)))
3277
+ } else {
3278
+ // Select all events in this group
3279
+ const newSelection = [...selectedEvents]
3280
+ groupEventIds.forEach((id) => {
3281
+ if (!newSelection.includes(id)) {
3282
+ newSelection.push(id)
3283
+ }
3284
+ })
3285
+ onSelectionChange(newSelection)
3286
+ }
3287
+ }
3288
+
3289
+ // Handle individual event selection
3290
+ const handleEventSelection = (eventId: string, selected: boolean) => {
3291
+ if (selected) {
3292
+ onSelectionChange([...selectedEvents, eventId])
3293
+ } else {
3294
+ onSelectionChange(selectedEvents.filter((id) => id !== eventId))
3295
+ }
3296
+ }
3297
+
3298
+ return (
3299
+ <div
3300
+ ref={ref}
3301
+ className={cn("bg-[#F9FAFB] rounded-lg", className)}
3302
+ {...props}
3303
+ >
3304
+ <Collapsible type="multiple">
3305
+ <CollapsibleItem value={group.id}>
3306
+ <CollapsibleTrigger
3307
+ showChevron={true}
3308
+ className="w-full p-4 hover:bg-[#F3F4F6] rounded-lg"
3309
+ >
3310
+ <div className="flex items-center gap-3 flex-1">
3311
+ <Checkbox
3312
+ checked={checkboxState}
3313
+ onCheckedChange={handleGroupCheckbox}
3314
+ onClick={(e) => e.stopPropagation()}
3315
+ aria-label={\`Select all \${group.name}\`}
3316
+ />
3317
+ <div className="flex flex-col items-start text-left flex-1 min-w-0">
3318
+ <div className="flex items-center gap-2">
3319
+ {group.icon && (
3320
+ <span className="text-[#6B7280]">{group.icon}</span>
3321
+ )}
3322
+ <span className="font-medium text-[#333333]">
3323
+ {group.name}
3324
+ </span>
3325
+ </div>
3326
+ <span className="text-sm text-[#6B7280] mt-0.5">
3327
+ {group.description}
3328
+ </span>
3329
+ </div>
3330
+ {selectedCount > 0 && (
3331
+ <span className="text-sm text-[#6B7280] whitespace-nowrap">
3332
+ {selectedCount} Selected
3333
+ </span>
3334
+ )}
3335
+ </div>
3336
+ </CollapsibleTrigger>
3337
+ <CollapsibleContent>
3338
+ <div className="border-t border-[#E5E7EB]">
3339
+ {events.length > 0 ? (
3340
+ events.map((event) => (
3341
+ <EventItemComponent
3342
+ key={event.id}
3343
+ event={event}
3344
+ isSelected={selectedEvents.includes(event.id)}
3345
+ onSelectionChange={(selected) =>
3346
+ handleEventSelection(event.id, selected)
3347
+ }
3348
+ />
3349
+ ))
3350
+ ) : renderEmptyGroup ? (
3351
+ <div className="py-4 px-8">
3352
+ {renderEmptyGroup(group)}
3353
+ </div>
3354
+ ) : (
3355
+ <div className="py-4 px-8 text-sm text-[#6B7280] italic">
3356
+ {emptyGroupMessage}
3357
+ </div>
3358
+ )}
3359
+ </div>
3360
+ </CollapsibleContent>
3361
+ </CollapsibleItem>
3362
+ </Collapsible>
3363
+ </div>
3364
+ )
3365
+ }
3366
+ )
3367
+ EventGroupComponent.displayName = "EventGroupComponent"
3368
+ `, prefix)
3369
+ },
3370
+ {
3371
+ name: "event-item.tsx",
3372
+ content: prefixTailwindClasses(`import * as React from "react"
3373
+ import { cn } from "../../../lib/utils"
3374
+ import { Checkbox } from "../checkbox"
3375
+ import type { EventItemComponentProps } from "./types"
3376
+
3377
+ /**
3378
+ * Individual event item with checkbox
3379
+ */
3380
+ export const EventItemComponent = React.forwardRef<
3381
+ HTMLDivElement,
3382
+ EventItemComponentProps & React.HTMLAttributes<HTMLDivElement>
3383
+ >(({ event, isSelected, onSelectionChange, className, ...props }, ref) => {
3384
+ return (
3385
+ <div
3386
+ ref={ref}
3387
+ className={cn(
3388
+ "flex items-start gap-3 py-2 pl-8 pr-4",
3389
+ className
3390
+ )}
3391
+ {...props}
3392
+ >
3393
+ <Checkbox
3394
+ checked={isSelected}
3395
+ onCheckedChange={(checked) => onSelectionChange(checked === true)}
3396
+ aria-label={\`Select \${event.name}\`}
3397
+ />
3398
+ <div className="flex-1 min-w-0">
3399
+ <div className="text-sm font-medium text-[#333333]">{event.name}</div>
3400
+ <div className="text-sm text-[#6B7280] mt-0.5 leading-relaxed">
3401
+ {event.description}
3402
+ </div>
3403
+ </div>
3404
+ </div>
3405
+ )
3406
+ })
3407
+ EventItemComponent.displayName = "EventItemComponent"
3408
+ `, prefix)
3409
+ },
3410
+ {
3411
+ name: "types.ts",
3412
+ content: prefixTailwindClasses(`import * as React from "react"
3413
+
3414
+ /**
3415
+ * Represents an individual event item
3416
+ */
3417
+ export interface EventItem {
3418
+ /** Unique identifier for the event */
3419
+ id: string
3420
+ /** Display name of the event (e.g., "Call.Initiated") */
3421
+ name: string
3422
+ /** Description of when this event is triggered */
3423
+ description: string
3424
+ /** Group ID this event belongs to */
3425
+ group: string
3426
+ }
3427
+
3428
+ /**
3429
+ * Represents a group of events
3430
+ */
3431
+ export interface EventGroup {
3432
+ /** Unique identifier for the group */
3433
+ id: string
3434
+ /** Display name of the group (e.g., "In-Call Events") */
3435
+ name: string
3436
+ /** Description of the group */
3437
+ description: string
3438
+ /** Optional icon to display next to the group name */
3439
+ icon?: React.ReactNode
3440
+ }
3441
+
3442
+ /**
3443
+ * Optional top-level category that can contain multiple groups
3444
+ */
3445
+ export interface EventCategory {
3446
+ /** Unique identifier for the category */
3447
+ id: string
3448
+ /** Display name of the category (e.g., "Call Events (Voice)") */
3449
+ name: string
3450
+ /** Optional icon to display next to the category name */
3451
+ icon?: React.ReactNode
3452
+ /** Array of group IDs that belong to this category */
3453
+ groups: string[]
3454
+ }
3455
+
3456
+ /**
3457
+ * Props for the EventSelector component
3458
+ */
3459
+ export interface EventSelectorProps {
3460
+ // Data
3461
+ /** Array of event items */
3462
+ events: EventItem[]
3463
+ /** Array of event groups */
3464
+ groups: EventGroup[]
3465
+ /** Optional array of categories for top-level grouping */
3466
+ categories?: EventCategory[]
3467
+
3468
+ // State (controlled mode)
3469
+ /** Array of selected event IDs (controlled) */
3470
+ selectedEvents?: string[]
3471
+ /** Callback when selection changes */
3472
+ onSelectionChange?: (selectedIds: string[]) => void
3473
+
3474
+ // State (uncontrolled mode)
3475
+ /** Default selected events for uncontrolled usage */
3476
+ defaultSelectedEvents?: string[]
3477
+
3478
+ // Customization
3479
+ /** Title displayed at the top (default: "Events") */
3480
+ title?: string
3481
+ /** Description displayed below the title */
3482
+ description?: string
3483
+ /** Message shown when a group has no events */
3484
+ emptyGroupMessage?: string
3485
+ /** Custom render function for empty group state (overrides emptyGroupMessage) */
3486
+ renderEmptyGroup?: (group: EventGroup) => React.ReactNode
3487
+
3488
+ // Styling
3489
+ /** Additional CSS classes for the root element */
3490
+ className?: string
3491
+ }
3492
+
3493
+ /**
3494
+ * Internal props for EventGroup component
3495
+ */
3496
+ export interface EventGroupComponentProps {
3497
+ /** The group data */
3498
+ group: EventGroup
3499
+ /** Events that belong to this group */
3500
+ events: EventItem[]
3501
+ /** Currently selected event IDs */
3502
+ selectedEvents: string[]
3503
+ /** Callback to update selected events */
3504
+ onSelectionChange: (selectedIds: string[]) => void
3505
+ /** Message shown when group has no events */
3506
+ emptyGroupMessage?: string
3507
+ /** Custom render function for empty group state */
3508
+ renderEmptyGroup?: (group: EventGroup) => React.ReactNode
3509
+ }
3510
+
3511
+ /**
3512
+ * Internal props for EventItem component
3513
+ */
3514
+ export interface EventItemComponentProps {
3515
+ /** The event data */
3516
+ event: EventItem
3517
+ /** Whether this event is selected */
3518
+ isSelected: boolean
3519
+ /** Callback when selection changes */
3520
+ onSelectionChange: (selected: boolean) => void
3521
+ }
3522
+ `, prefix)
3523
+ }
3524
+ ]
3525
+ },
3526
+ "key-value-input": {
3527
+ name: "key-value-input",
3528
+ description: "A component for managing key-value pairs with validation and duplicate detection",
3529
+ dependencies: [
3530
+ "clsx",
3531
+ "tailwind-merge",
3532
+ "lucide-react"
3533
+ ],
3534
+ internalDependencies: [
3535
+ "button",
3536
+ "input"
3537
+ ],
3538
+ isMultiFile: true,
3539
+ directory: "key-value-input",
3540
+ mainFile: "key-value-input.tsx",
3541
+ files: [
3542
+ {
3543
+ name: "key-value-input.tsx",
3544
+ content: prefixTailwindClasses(`import * as React from "react"
3545
+ import { Plus } from "lucide-react"
3546
+ import { cn } from "../../../lib/utils"
3547
+ import { Button } from "../button"
3548
+ import { KeyValueRow } from "./key-value-row"
3549
+ import type { KeyValueInputProps, KeyValuePair } from "./types"
3550
+
3551
+ // Helper to generate unique IDs
3552
+ const generateId = () =>
3553
+ \`kv-\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`
3554
+
3555
+ /**
3556
+ * KeyValueInput - A component for managing key-value pairs
3557
+ *
3558
+ * Install via CLI:
3559
+ * \`\`\`bash
3560
+ * npx myoperator-ui add key-value-input
3561
+ * \`\`\`
3562
+ *
3563
+ * Or import directly from npm:
3564
+ * \`\`\`tsx
3565
+ * import { KeyValueInput } from "@myoperator/ui"
3566
+ * \`\`\`
3567
+ *
3568
+ * @example
3569
+ * \`\`\`tsx
3570
+ * <KeyValueInput
3571
+ * title="HTTP Headers"
3572
+ * description="Add custom headers for the webhook request"
3573
+ * value={headers}
3574
+ * onChange={setHeaders}
3575
+ * />
3576
+ * \`\`\`
3577
+ */
3578
+ export const KeyValueInput = React.forwardRef<
3579
+ HTMLDivElement,
3580
+ KeyValueInputProps
3581
+ >(
3582
+ (
3583
+ {
3584
+ title,
3585
+ description,
3586
+ addButtonText = "Add Header",
3587
+ maxItems = 10,
3588
+ keyPlaceholder = "Key",
3589
+ valuePlaceholder = "Value",
3590
+ keyLabel = "Key",
3591
+ valueLabel = "Value",
3592
+ value: controlledValue,
3593
+ onChange,
3594
+ defaultValue = [],
3595
+ className,
3596
+ ...props
3597
+ },
3598
+ ref
3599
+ ) => {
3600
+ // Controlled vs uncontrolled state
3601
+ const [internalPairs, setInternalPairs] =
3602
+ React.useState<KeyValuePair[]>(defaultValue)
3603
+
3604
+ const isControlled = controlledValue !== undefined
3605
+ const pairs = isControlled ? controlledValue : internalPairs
3606
+
3607
+ // Track which keys have been touched for validation
3608
+ const [touchedKeys, setTouchedKeys] = React.useState<Set<string>>(new Set())
3609
+
3610
+ const handlePairsChange = React.useCallback(
3611
+ (newPairs: KeyValuePair[]) => {
3612
+ if (!isControlled) {
3613
+ setInternalPairs(newPairs)
3614
+ }
3615
+ onChange?.(newPairs)
3616
+ },
3617
+ [isControlled, onChange]
3618
+ )
3619
+
3620
+ // Check for duplicate keys (case-insensitive)
3621
+ const getDuplicateKeys = React.useCallback((): Set<string> => {
3622
+ const keyCount = new Map<string, number>()
3623
+ pairs.forEach((pair) => {
3624
+ if (pair.key.trim()) {
3625
+ const key = pair.key.toLowerCase()
3626
+ keyCount.set(key, (keyCount.get(key) || 0) + 1)
3627
+ }
3628
+ })
3629
+ const duplicates = new Set<string>()
3630
+ keyCount.forEach((count, key) => {
3631
+ if (count > 1) duplicates.add(key)
3632
+ })
3633
+ return duplicates
3634
+ }, [pairs])
3635
+
3636
+ const duplicateKeys = getDuplicateKeys()
3637
+
3638
+ // Add new row
3639
+ const handleAdd = () => {
3640
+ if (pairs.length >= maxItems) return
3641
+ const newPair: KeyValuePair = {
3642
+ id: generateId(),
3643
+ key: "",
3644
+ value: "",
3645
+ }
3646
+ handlePairsChange([...pairs, newPair])
3647
+ }
3648
+
3649
+ // Update key
3650
+ const handleKeyChange = (id: string, key: string) => {
3651
+ handlePairsChange(
3652
+ pairs.map((pair) => (pair.id === id ? { ...pair, key } : pair))
3653
+ )
3654
+ setTouchedKeys((prev) => new Set(prev).add(id))
3655
+ }
3656
+
3657
+ // Update value
3658
+ const handleValueChange = (id: string, value: string) => {
3659
+ handlePairsChange(
3660
+ pairs.map((pair) => (pair.id === id ? { ...pair, value } : pair))
3661
+ )
3662
+ }
3663
+
3664
+ // Delete row
3665
+ const handleDelete = (id: string) => {
3666
+ handlePairsChange(pairs.filter((pair) => pair.id !== id))
3667
+ setTouchedKeys((prev) => {
3668
+ const next = new Set(prev)
3669
+ next.delete(id)
3670
+ return next
3671
+ })
3672
+ }
3673
+
3674
+ const isAtLimit = pairs.length >= maxItems
3675
+ const addButtonTitle = isAtLimit
3676
+ ? \`Maximum of \${maxItems} items allowed\`
3677
+ : undefined
3678
+
3679
+ return (
3680
+ <div ref={ref} className={cn("w-full", className)} {...props}>
3681
+ {/* Header */}
3682
+ <div className="mb-3">
3683
+ <h3 className="text-base font-semibold text-[#333333]">{title}</h3>
3684
+ {description && (
3685
+ <p className="text-sm text-[#6B7280] mt-1">{description}</p>
3686
+ )}
3687
+ </div>
3688
+
3689
+ {/* Content Container with Background - only show when there are items */}
3690
+ {pairs.length > 0 && (
3691
+ <div className="bg-[#F9FAFB] rounded-lg p-4 mb-4">
3692
+ {/* Column Headers */}
3693
+ <div className="flex items-center gap-3 mb-3">
3694
+ <div className="flex-1">
3695
+ <span className="text-sm font-medium text-[#333333]">
3696
+ {keyLabel}
3697
+ <span className="text-[#FF3B3B] ml-0.5">*</span>
3698
+ </span>
3699
+ </div>
3700
+ <div className="flex-1">
3701
+ <span className="text-sm font-medium text-[#333333]">
3702
+ {valueLabel}
3703
+ </span>
3704
+ </div>
3705
+ {/* Spacer for delete button column */}
3706
+ <div className="w-8 flex-shrink-0" />
3707
+ </div>
3708
+
3709
+ {/* Rows */}
3710
+ <div className="space-y-3">
3711
+ {pairs.map((pair) => (
3712
+ <KeyValueRow
3713
+ key={pair.id}
3714
+ pair={pair}
3715
+ isDuplicateKey={duplicateKeys.has(pair.key.toLowerCase())}
3716
+ isKeyEmpty={touchedKeys.has(pair.id) && !pair.key.trim()}
3717
+ keyPlaceholder={keyPlaceholder}
3718
+ valuePlaceholder={valuePlaceholder}
3719
+ onKeyChange={handleKeyChange}
3720
+ onValueChange={handleValueChange}
3721
+ onDelete={handleDelete}
3722
+ />
3723
+ ))}
3724
+ </div>
3725
+ </div>
3726
+ )}
3727
+
3728
+ {/* Add Button using dashed variant - outside the gray container */}
3729
+ <Button
3730
+ type="button"
3731
+ variant="dashed"
3732
+ onClick={handleAdd}
3733
+ disabled={isAtLimit}
3734
+ title={addButtonTitle}
3735
+ className="w-full justify-center"
3736
+ >
3737
+ <Plus className="h-4 w-4" />
3738
+ {addButtonText}
3739
+ </Button>
3740
+
3741
+ {/* Limit indicator */}
3742
+ {isAtLimit && (
3743
+ <p className="text-xs text-[#6B7280] mt-2 text-center">
3744
+ Maximum of {maxItems} items reached
3745
+ </p>
3746
+ )}
3747
+ </div>
3748
+ )
3749
+ }
3750
+ )
3751
+ KeyValueInput.displayName = "KeyValueInput"
3752
+ `, prefix)
3753
+ },
3754
+ {
3755
+ name: "key-value-row.tsx",
3756
+ content: prefixTailwindClasses(`import * as React from "react"
3757
+ import { Trash2 } from "lucide-react"
3758
+ import { cn } from "../../../lib/utils"
3759
+ import { Input } from "../input"
3760
+ import { Button } from "../button"
3761
+ import type { KeyValueRowProps } from "./types"
3762
+
3763
+ /**
3764
+ * Individual key-value pair row with inputs and delete button
3765
+ */
3766
+ export const KeyValueRow = React.forwardRef<
3767
+ HTMLDivElement,
3768
+ KeyValueRowProps & React.HTMLAttributes<HTMLDivElement>
3769
+ >(
3770
+ (
3771
+ {
3772
+ pair,
3773
+ isDuplicateKey,
3774
+ isKeyEmpty,
3775
+ keyPlaceholder = "Key",
3776
+ valuePlaceholder = "Value",
3777
+ onKeyChange,
3778
+ onValueChange,
3779
+ onDelete,
3780
+ className,
3781
+ ...props
3782
+ },
3783
+ ref
3784
+ ) => {
3785
+ // Determine if key input should show error state
3786
+ const keyHasError = isDuplicateKey || isKeyEmpty
3787
+
3788
+ // Determine error message
3789
+ const errorMessage = isDuplicateKey
3790
+ ? "Duplicate key"
3791
+ : isKeyEmpty
3792
+ ? "Key is required"
3793
+ : null
3794
+
3795
+ return (
3796
+ <div
3797
+ ref={ref}
3798
+ className={cn("flex items-start gap-3", className)}
3799
+ {...props}
3800
+ >
3801
+ {/* Key Input */}
3802
+ <div className="flex-1">
3803
+ <Input
3804
+ value={pair.key}
3805
+ onChange={(e) => onKeyChange(pair.id, e.target.value)}
3806
+ placeholder={keyPlaceholder}
3807
+ state={keyHasError ? "error" : "default"}
3808
+ aria-label="Key"
3809
+ />
3810
+ {errorMessage && (
3811
+ <span className="text-xs text-[#FF3B3B] mt-1 block">
3812
+ {errorMessage}
3813
+ </span>
3814
+ )}
3815
+ </div>
3816
+
3817
+ {/* Value Input */}
3818
+ <div className="flex-1">
3819
+ <Input
3820
+ value={pair.value}
3821
+ onChange={(e) => onValueChange(pair.id, e.target.value)}
3822
+ placeholder={valuePlaceholder}
3823
+ aria-label="Value"
3824
+ />
3825
+ </div>
3826
+
3827
+ {/* Delete Button */}
3828
+ <Button
3829
+ type="button"
3830
+ variant="ghost"
3831
+ size="icon"
3832
+ onClick={() => onDelete(pair.id)}
3833
+ className="text-gray-400 hover:text-[#EF4444] hover:bg-[#FEF2F2] flex-shrink-0"
3834
+ aria-label="Delete row"
3835
+ >
3836
+ <Trash2 className="h-4 w-4" />
3837
+ </Button>
3838
+ </div>
3839
+ )
3840
+ }
3841
+ )
3842
+ KeyValueRow.displayName = "KeyValueRow"
3843
+ `, prefix)
3844
+ },
3845
+ {
3846
+ name: "types.ts",
3847
+ content: prefixTailwindClasses(`import * as React from "react"
3848
+
3849
+ /**
3850
+ * Represents a single key-value pair
3851
+ */
3852
+ export interface KeyValuePair {
3853
+ /** Unique identifier for the pair */
3854
+ id: string
3855
+ /** The key (e.g., header name) */
3856
+ key: string
3857
+ /** The value (e.g., header value) */
3858
+ value: string
3859
+ }
3860
+
3861
+ /**
3862
+ * Props for the KeyValueInput component
3863
+ */
3864
+ export interface KeyValueInputProps {
3865
+ // Customization
3866
+ /** Title displayed at the top (e.g., "HTTP Headers") */
3867
+ title: string
3868
+ /** Description displayed below the title */
3869
+ description?: string
3870
+ /** Text for the add button (default: "Add Header") */
3871
+ addButtonText?: string
3872
+ /** Maximum number of items allowed (default: 10) */
3873
+ maxItems?: number
3874
+ /** Placeholder for key input */
3875
+ keyPlaceholder?: string
3876
+ /** Placeholder for value input */
3877
+ valuePlaceholder?: string
3878
+ /** Label for key column header (default: "Key") */
3879
+ keyLabel?: string
3880
+ /** Label for value column header (default: "Value") */
3881
+ valueLabel?: string
3882
+
3883
+ // State (controlled mode)
3884
+ /** Array of key-value pairs (controlled) */
3885
+ value?: KeyValuePair[]
3886
+ /** Callback when pairs change */
3887
+ onChange?: (pairs: KeyValuePair[]) => void
3888
+
3889
+ // State (uncontrolled mode)
3890
+ /** Default key-value pairs for uncontrolled usage */
3891
+ defaultValue?: KeyValuePair[]
3892
+
3893
+ // Styling
3894
+ /** Additional CSS classes for the root element */
3895
+ className?: string
3896
+ }
3897
+
3898
+ /**
3899
+ * Internal props for KeyValueRow component
3900
+ */
3901
+ export interface KeyValueRowProps {
3902
+ /** The key-value pair data */
3903
+ pair: KeyValuePair
3904
+ /** Whether the key is a duplicate */
3905
+ isDuplicateKey: boolean
3906
+ /** Whether key is empty (for validation) */
3907
+ isKeyEmpty: boolean
3908
+ /** Placeholder for key input */
3909
+ keyPlaceholder?: string
3910
+ /** Placeholder for value input */
3911
+ valuePlaceholder?: string
3912
+ /** Callback when key changes */
3913
+ onKeyChange: (id: string, key: string) => void
3914
+ /** Callback when value changes */
3915
+ onValueChange: (id: string, value: string) => void
3916
+ /** Callback when row is deleted */
3917
+ onDelete: (id: string) => void
3918
+ }
3017
3919
  `, prefix)
3018
3920
  }
3019
3921
  ]
@@ -3152,10 +4054,26 @@ async function add(components, options) {
3152
4054
  try {
3153
4055
  const installed = [];
3154
4056
  const dependencies = /* @__PURE__ */ new Set();
3155
- for (const componentName of components) {
4057
+ const installedComponents = /* @__PURE__ */ new Set();
4058
+ const installComponent = async (componentName) => {
4059
+ if (installedComponents.has(componentName)) {
4060
+ return;
4061
+ }
3156
4062
  const component = registry[componentName];
4063
+ if (!component) {
4064
+ spinner.warn(`Component ${componentName} not found in registry`);
4065
+ return;
4066
+ }
4067
+ if (component.internalDependencies && component.internalDependencies.length > 0) {
4068
+ spinner.text = `Installing dependencies for ${componentName}...`;
4069
+ for (const depName of component.internalDependencies) {
4070
+ await installComponent(depName);
4071
+ }
4072
+ }
4073
+ spinner.text = `Installing ${componentName}...`;
4074
+ const targetDir = component.isMultiFile ? path2.join(componentsDir, component.directory) : componentsDir;
3157
4075
  for (const file of component.files) {
3158
- const filePath = path2.join(componentsDir, file.name);
4076
+ const filePath = path2.join(targetDir, file.name);
3159
4077
  if (await fs2.pathExists(filePath)) {
3160
4078
  if (!options.overwrite) {
3161
4079
  spinner.warn(`${file.name} already exists. Use --overwrite to replace.`);
@@ -3164,11 +4082,16 @@ async function add(components, options) {
3164
4082
  }
3165
4083
  await fs2.ensureDir(path2.dirname(filePath));
3166
4084
  await fs2.writeFile(filePath, file.content);
3167
- installed.push(file.name);
4085
+ const relativePath = component.isMultiFile ? `${component.directory}/${file.name}` : file.name;
4086
+ installed.push(relativePath);
3168
4087
  }
3169
4088
  if (component.dependencies) {
3170
4089
  component.dependencies.forEach((dep) => dependencies.add(dep));
3171
4090
  }
4091
+ installedComponents.add(componentName);
4092
+ };
4093
+ for (const componentName of components) {
4094
+ await installComponent(componentName);
3172
4095
  }
3173
4096
  spinner.succeed("Components installed successfully!");
3174
4097
  if (installed.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myoperator-ui",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "description": "CLI for adding myOperator UI components to your project",
5
5
  "type": "module",
6
6
  "exports": "./dist/index.js",