@srcroot/ui 0.0.1 → 0.0.3

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.
@@ -1,7 +1,7 @@
1
1
  import * as React from "react"
2
2
  import { cn } from "@/lib/utils"
3
3
 
4
- interface SliderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
4
+ interface SliderProps {
5
5
  value?: number[]
6
6
  onValueChange?: (value: number[]) => void
7
7
  defaultValue?: number[]
@@ -9,14 +9,20 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChan
9
9
  max?: number
10
10
  step?: number
11
11
  disabled?: boolean
12
+ minStepsBetweenThumbs?: number
13
+ className?: string
12
14
  }
13
15
 
14
16
  /**
15
- * Slider component with keyboard support
17
+ * Slider component with support for multiple thumbs (range selection)
16
18
  *
17
19
  * @example
18
- * const [value, setValue] = useState([50])
19
- * <Slider value={value} onValueChange={setValue} max={100} step={1} />
20
+ * // Single value
21
+ * <Slider value={[50]} onValueChange={setValue} max={100} step={1} />
22
+ *
23
+ * @example
24
+ * // Range
25
+ * <Slider value={[25, 75]} onValueChange={setValue} max={100} step={1} />
20
26
  */
21
27
  const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
22
28
  ({
@@ -28,84 +34,157 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
28
34
  max = 100,
29
35
  step = 1,
30
36
  disabled,
37
+ minStepsBetweenThumbs = 0,
31
38
  ...props
32
39
  }, ref) => {
33
40
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
41
+ const trackRef = React.useRef<HTMLDivElement>(null)
42
+ const activeThumbIndex = React.useRef<number | null>(null)
34
43
 
35
44
  const value = controlledValue !== undefined ? controlledValue : uncontrolledValue
36
- const setValue = onValueChange || setUncontrolledValue
37
- const currentValue = value[0] || 0
45
+ const setValue = (newValue: number[]) => {
46
+ if (controlledValue === undefined) {
47
+ setUncontrolledValue(newValue)
48
+ }
49
+ onValueChange?.(newValue)
50
+ }
51
+
52
+ const updateValue = (clientX: number, thumbIndex: number) => {
53
+ if (!trackRef.current) return
54
+
55
+ const rect = trackRef.current.getBoundingClientRect()
56
+ const percentage = (clientX - rect.left) / rect.width
57
+ const rawValue = min + percentage * (max - min)
58
+ const steppedValue = Math.round(rawValue / step) * step
59
+ const clampedValue = Math.min(Math.max(steppedValue, min), max)
60
+
61
+ const newValue = [...value]
62
+ newValue[thumbIndex] = clampedValue
38
63
 
39
- const percentage = ((currentValue - min) / (max - min)) * 100
64
+ // Sort logic to prevent crossover if preferred, or just allow it but sorted
65
+ newValue.sort((a, b) => a - b)
66
+
67
+ setValue(newValue)
68
+ }
69
+
70
+ // Find closest thumb to a point
71
+ const getClosestThumbIndex = (clientX: number) => {
72
+ if (!trackRef.current) return 0
73
+ const rect = trackRef.current.getBoundingClientRect()
74
+ const percentage = (clientX - rect.left) / rect.width
75
+ const clickedValue = min + percentage * (max - min)
76
+
77
+ let closestIndex = 0
78
+ let minDiff = Infinity
79
+
80
+ value.forEach((val, index) => {
81
+ const diff = Math.abs(val - clickedValue)
82
+ if (diff < minDiff) {
83
+ minDiff = diff
84
+ closestIndex = index
85
+ }
86
+ })
87
+
88
+ return closestIndex
89
+ }
40
90
 
41
- const handleKeyDown = (e: React.KeyboardEvent) => {
91
+ const handleMouseDown = (e: React.MouseEvent) => {
42
92
  if (disabled) return
93
+ const thumbIndex = getClosestThumbIndex(e.clientX)
94
+ activeThumbIndex.current = thumbIndex
43
95
 
44
- let newValue = currentValue
45
-
46
- switch (e.key) {
47
- case "ArrowRight":
48
- case "ArrowUp":
49
- newValue = Math.min(currentValue + step, max)
50
- break
51
- case "ArrowLeft":
52
- case "ArrowDown":
53
- newValue = Math.max(currentValue - step, min)
54
- break
55
- case "Home":
56
- newValue = min
57
- break
58
- case "End":
59
- newValue = max
60
- break
61
- default:
62
- return
63
- }
96
+ updateValue(e.clientX, thumbIndex)
64
97
 
65
- e.preventDefault()
66
- setValue([newValue])
98
+ document.addEventListener('mousemove', handleMouseMove)
99
+ document.addEventListener('mouseup', handleMouseUp)
100
+ }
101
+
102
+ const handleMouseMove = (e: MouseEvent) => {
103
+ if (activeThumbIndex.current === null) return
104
+ updateValue(e.clientX, activeThumbIndex.current)
67
105
  }
68
106
 
69
- const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
107
+ const handleMouseUp = () => {
108
+ activeThumbIndex.current = null
109
+ document.removeEventListener('mousemove', handleMouseMove)
110
+ document.removeEventListener('mouseup', handleMouseUp)
111
+ }
112
+
113
+ const handleTouchStart = (e: React.TouchEvent) => {
70
114
  if (disabled) return
115
+ const thumbIndex = getClosestThumbIndex(e.touches[0].clientX)
116
+ activeThumbIndex.current = thumbIndex
117
+
118
+ updateValue(e.touches[0].clientX, thumbIndex)
71
119
 
72
- const rect = e.currentTarget.getBoundingClientRect()
73
- const clickPosition = (e.clientX - rect.left) / rect.width
74
- const newValue = min + clickPosition * (max - min)
75
- const steppedValue = Math.round(newValue / step) * step
76
- setValue([Math.min(Math.max(steppedValue, min), max)])
120
+ document.addEventListener('touchmove', handleTouchMove)
121
+ document.addEventListener('touchend', handleTouchEnd)
122
+ }
123
+
124
+ const handleTouchMove = (e: TouchEvent) => {
125
+ if (activeThumbIndex.current === null) return
126
+ updateValue(e.touches[0].clientX, activeThumbIndex.current)
127
+ e.preventDefault()
128
+ }
129
+
130
+ const handleTouchEnd = () => {
131
+ activeThumbIndex.current = null
132
+ document.removeEventListener('touchmove', handleTouchMove)
133
+ document.removeEventListener('touchend', handleTouchEnd)
77
134
  }
78
135
 
79
136
  return (
80
137
  <div
81
138
  ref={ref}
82
- role="slider"
83
- aria-valuenow={currentValue}
84
- aria-valuemin={min}
85
- aria-valuemax={max}
86
- aria-disabled={disabled}
87
- tabIndex={disabled ? -1 : 0}
139
+ role="group"
88
140
  className={cn(
89
- "relative flex w-full touch-none select-none items-center",
141
+ "relative flex w-full touch-none select-none items-center py-4 cursor-pointer",
90
142
  disabled && "opacity-50 cursor-not-allowed",
91
143
  className
92
144
  )}
93
- onKeyDown={handleKeyDown}
145
+ onMouseDown={handleMouseDown}
146
+ onTouchStart={handleTouchStart}
94
147
  {...props}
95
148
  >
96
149
  <div
97
- className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20 cursor-pointer"
98
- onClick={handleTrackClick}
150
+ ref={trackRef}
151
+ className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary"
99
152
  >
100
- <div
101
- className="absolute h-full bg-primary"
102
- style={{ width: `${percentage}%` }}
103
- />
153
+ {/* Render active tracks between ranges if multiple, or from 0 if single */}
154
+ {value.length > 1 ? (
155
+ <div
156
+ className="absolute h-full bg-primary transition-all duration-75"
157
+ style={{
158
+ left: `${((value[0] - min) / (max - min)) * 100}%`,
159
+ right: `${100 - ((value[value.length - 1] - min) / (max - min)) * 100}%`,
160
+ }}
161
+ />
162
+ ) : (
163
+ <div
164
+ className="absolute h-full bg-primary transition-all duration-75"
165
+ style={{ width: `${((value[0] - min) / (max - min)) * 100}%` }}
166
+ />
167
+ )}
104
168
  </div>
105
- <div
106
- className="absolute block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
107
- style={{ left: `calc(${percentage}% - 8px)` }}
108
- />
169
+
170
+ {value.map((val, index) => {
171
+ const percentage = ((val - min) / (max - min)) * 100
172
+ return (
173
+ <div
174
+ key={index}
175
+ role="slider"
176
+ aria-valuemin={min}
177
+ aria-valuemax={max}
178
+ aria-valuenow={val}
179
+ tabIndex={disabled ? -1 : 0}
180
+ className={cn(
181
+ "absolute block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
182
+ "hover:bg-accent hover:border-primary"
183
+ )}
184
+ style={{ left: `calc(${percentage}% - 8px)` }}
185
+ />
186
+ )
187
+ })}
109
188
  </div>
110
189
  )
111
190
  }
package/registry/text.tsx CHANGED
@@ -26,13 +26,14 @@ const textVariants = cva("", {
26
26
 
27
27
  type TextVariants = VariantProps<typeof textVariants>
28
28
 
29
- interface TextBaseProps extends TextVariants {
29
+ interface TextProps extends TextVariants {
30
30
  className?: string
31
31
  children?: React.ReactNode
32
+ as?: "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "label"
32
33
  }
33
34
 
34
35
  /**
35
- * Polymorphic Text component for typography
36
+ * Text component for typography
36
37
  *
37
38
  * @example
38
39
  * // As a heading
@@ -44,22 +45,12 @@ interface TextBaseProps extends TextVariants {
44
45
  * // Muted text
45
46
  * <Text variant="muted">Secondary information</Text>
46
47
  */
47
- const Text = React.forwardRef(
48
- <T extends React.ElementType = "p">(
49
- {
50
- as,
51
- className,
52
- variant,
53
- ...props
54
- }: TextBaseProps & { as?: T } & Omit<React.ComponentPropsWithoutRef<T>, keyof TextBaseProps | "as">,
55
- ref: React.ForwardedRef<React.ElementRef<T>>
56
- ) => {
57
- const Comp = as || "p"
58
-
48
+ const Text = React.forwardRef<HTMLElement, TextProps>(
49
+ ({ as: Component = "p", className, variant, ...props }, ref) => {
59
50
  return (
60
- <Comp
51
+ <Component
61
52
  ref={ref as any}
62
- className={cn(textVariants({ variant, className }))}
53
+ className={cn(textVariants({ variant }), className)}
63
54
  {...props}
64
55
  />
65
56
  )
@@ -0,0 +1,129 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+ import { toggleVariants } from "./toggle"
6
+ import { type VariantProps } from "class-variance-authority"
7
+
8
+ // ToggleGroup Context
9
+ interface ToggleGroupContextValue {
10
+ type: "single" | "multiple"
11
+ value: string[]
12
+ onValueChange: (value: string) => void
13
+ variant?: VariantProps<typeof toggleVariants>["variant"]
14
+ size?: VariantProps<typeof toggleVariants>["size"]
15
+ }
16
+
17
+ const ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null)
18
+
19
+ function useToggleGroup() {
20
+ const context = React.useContext(ToggleGroupContext)
21
+ if (!context) {
22
+ throw new Error("useToggleGroup must be used within a ToggleGroup")
23
+ }
24
+ return context
25
+ }
26
+
27
+ // ToggleGroup Props
28
+ interface ToggleGroupSingleProps {
29
+ type: "single"
30
+ value?: string
31
+ defaultValue?: string
32
+ onValueChange?: (value: string) => void
33
+ }
34
+
35
+ interface ToggleGroupMultipleProps {
36
+ type: "multiple"
37
+ value?: string[]
38
+ defaultValue?: string[]
39
+ onValueChange?: (value: string[]) => void
40
+ }
41
+
42
+ type ToggleGroupProps = (ToggleGroupSingleProps | ToggleGroupMultipleProps) &
43
+ React.HTMLAttributes<HTMLDivElement> &
44
+ VariantProps<typeof toggleVariants>
45
+
46
+ const ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(
47
+ ({ className, type, value: controlledValue, defaultValue, onValueChange, variant, size, children, ...props }, ref) => {
48
+ // Handle both single and multiple types
49
+ const [uncontrolledValue, setUncontrolledValue] = React.useState<string[]>(() => {
50
+ if (type === "single") {
51
+ return defaultValue ? [defaultValue as string] : []
52
+ }
53
+ return (defaultValue as string[]) || []
54
+ })
55
+
56
+ const value = controlledValue !== undefined
57
+ ? (type === "single" ? [controlledValue as string] : controlledValue as string[])
58
+ : uncontrolledValue
59
+
60
+ const handleValueChange = (itemValue: string) => {
61
+ let newValue: string[]
62
+
63
+ if (type === "single") {
64
+ // Toggle off if clicking same value, otherwise set new value
65
+ newValue = value.includes(itemValue) ? [] : [itemValue]
66
+ if (controlledValue === undefined) {
67
+ setUncontrolledValue(newValue)
68
+ }
69
+ ; (onValueChange as ((value: string) => void))?.(newValue[0] || "")
70
+ } else {
71
+ // Toggle item in array
72
+ newValue = value.includes(itemValue)
73
+ ? value.filter(v => v !== itemValue)
74
+ : [...value, itemValue]
75
+ if (controlledValue === undefined) {
76
+ setUncontrolledValue(newValue)
77
+ }
78
+ ; (onValueChange as ((value: string[]) => void))?.(newValue)
79
+ }
80
+ }
81
+
82
+ return (
83
+ <ToggleGroupContext.Provider value={{ type, value, onValueChange: handleValueChange, variant, size }}>
84
+ <div
85
+ ref={ref}
86
+ role="group"
87
+ className={cn("flex items-center justify-center gap-1", className)}
88
+ {...props}
89
+ >
90
+ {children}
91
+ </div>
92
+ </ToggleGroupContext.Provider>
93
+ )
94
+ }
95
+ )
96
+ ToggleGroup.displayName = "ToggleGroup"
97
+
98
+ // ToggleGroupItem
99
+ interface ToggleGroupItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
100
+ value: string
101
+ }
102
+
103
+ const ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
104
+ ({ className, value, children, ...props }, ref) => {
105
+ const context = useToggleGroup()
106
+ const pressed = context.value.includes(value)
107
+
108
+ return (
109
+ <button
110
+ ref={ref}
111
+ type="button"
112
+ aria-pressed={pressed}
113
+ data-state={pressed ? "on" : "off"}
114
+ className={cn(
115
+ toggleVariants({ variant: context.variant, size: context.size }),
116
+ pressed && "bg-accent text-accent-foreground",
117
+ className
118
+ )}
119
+ onClick={() => context.onValueChange(value)}
120
+ {...props}
121
+ >
122
+ {children}
123
+ </button>
124
+ )
125
+ }
126
+ )
127
+ ToggleGroupItem.displayName = "ToggleGroupItem"
128
+
129
+ export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,72 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cva, type VariantProps } from "class-variance-authority"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const toggleVariants = cva(
8
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-transparent",
13
+ outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
14
+ },
15
+ size: {
16
+ default: "h-10 px-3",
17
+ sm: "h-9 px-2.5",
18
+ lg: "h-11 px-5",
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ variant: "default",
23
+ size: "default",
24
+ },
25
+ }
26
+ )
27
+
28
+ export interface ToggleProps
29
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
30
+ VariantProps<typeof toggleVariants> {
31
+ /** Controlled pressed state */
32
+ pressed?: boolean
33
+ /** Default pressed state for uncontrolled usage */
34
+ defaultPressed?: boolean
35
+ /** Callback when pressed state changes */
36
+ onPressedChange?: (pressed: boolean) => void
37
+ }
38
+
39
+ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
40
+ ({ className, variant, size, pressed: controlledPressed, defaultPressed = false, onPressedChange, ...props }, ref) => {
41
+ const [uncontrolledPressed, setUncontrolledPressed] = React.useState(defaultPressed)
42
+ const pressed = controlledPressed ?? uncontrolledPressed
43
+
44
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
45
+ const newPressed = !pressed
46
+ if (controlledPressed === undefined) {
47
+ setUncontrolledPressed(newPressed)
48
+ }
49
+ onPressedChange?.(newPressed)
50
+ props.onClick?.(e)
51
+ }
52
+
53
+ return (
54
+ <button
55
+ ref={ref}
56
+ type="button"
57
+ aria-pressed={pressed}
58
+ data-state={pressed ? "on" : "off"}
59
+ className={cn(
60
+ toggleVariants({ variant, size }),
61
+ pressed && "bg-accent text-accent-foreground",
62
+ className
63
+ )}
64
+ onClick={handleClick}
65
+ {...props}
66
+ />
67
+ )
68
+ }
69
+ )
70
+ Toggle.displayName = "Toggle"
71
+
72
+ export { Toggle, toggleVariants }
@@ -4,6 +4,8 @@ import { cn } from "@/lib/utils"
4
4
  interface TooltipContextValue {
5
5
  open: boolean
6
6
  setOpen: (open: boolean) => void
7
+ mousePosition: { x: number; y: number }
8
+ setMousePosition: (pos: { x: number; y: number }) => void
7
9
  }
8
10
 
9
11
  const TooltipContext = React.createContext<TooltipContextValue | null>(null)
@@ -28,7 +30,7 @@ interface TooltipProps {
28
30
  }
29
31
 
30
32
  /**
31
- * Tooltip component for hover hints
33
+ * Tooltip component for hover hints - appears at mouse cursor position
32
34
  *
33
35
  * @example
34
36
  * <TooltipProvider>
@@ -40,12 +42,13 @@ interface TooltipProps {
40
42
  */
41
43
  function Tooltip({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: TooltipProps) {
42
44
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
45
+ const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 })
43
46
 
44
47
  const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
45
48
  const setOpen = onOpenChange || setUncontrolledOpen
46
49
 
47
50
  return (
48
- <TooltipContext.Provider value={{ open, setOpen }}>
51
+ <TooltipContext.Provider value={{ open, setOpen, mousePosition, setMousePosition }}>
49
52
  <span className="relative inline-block">
50
53
  {children}
51
54
  </span>
@@ -64,6 +67,9 @@ const TooltipTrigger = React.forwardRef<HTMLSpanElement, TooltipTriggerProps>(
64
67
 
65
68
  const handleMouseEnter = () => context.setOpen(true)
66
69
  const handleMouseLeave = () => context.setOpen(false)
70
+ const handleMouseMove = (e: React.MouseEvent) => {
71
+ context.setMousePosition({ x: e.clientX, y: e.clientY })
72
+ }
67
73
  const handleFocus = () => context.setOpen(true)
68
74
  const handleBlur = () => context.setOpen(false)
69
75
 
@@ -71,6 +77,7 @@ const TooltipTrigger = React.forwardRef<HTMLSpanElement, TooltipTriggerProps>(
71
77
  return React.cloneElement(children as React.ReactElement<any>, {
72
78
  onMouseEnter: handleMouseEnter,
73
79
  onMouseLeave: handleMouseLeave,
80
+ onMouseMove: handleMouseMove,
74
81
  onFocus: handleFocus,
75
82
  onBlur: handleBlur,
76
83
  ref,
@@ -82,6 +89,7 @@ const TooltipTrigger = React.forwardRef<HTMLSpanElement, TooltipTriggerProps>(
82
89
  ref={ref}
83
90
  onMouseEnter={handleMouseEnter}
84
91
  onMouseLeave={handleMouseLeave}
92
+ onMouseMove={handleMouseMove}
85
93
  onFocus={handleFocus}
86
94
  onBlur={handleBlur}
87
95
  tabIndex={0}
@@ -103,12 +111,22 @@ const TooltipContent = React.forwardRef<
103
111
 
104
112
  if (!context.open) return null
105
113
 
114
+ // Offset from cursor (slightly above and to the right)
115
+ const offsetX = 10
116
+ const offsetY = -10
117
+
106
118
  return (
107
119
  <div
108
120
  ref={ref}
109
121
  role="tooltip"
122
+ style={{
123
+ position: 'fixed',
124
+ left: context.mousePosition.x + offsetX,
125
+ top: context.mousePosition.y + offsetY,
126
+ transform: 'translateY(-100%)',
127
+ }}
110
128
  className={cn(
111
- "absolute left-1/2 bottom-full z-50 mb-2 -translate-x-1/2 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95",
129
+ "z-[9999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 pointer-events-none whitespace-nowrap",
112
130
  className
113
131
  )}
114
132
  {...props}