myoperator-ui 0.0.67 → 0.0.69

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