@bearmenu/ui 0.5.2 → 0.6.1

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 (227) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +4 -1
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +3 -1
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +16 -3
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/scroll-fade.d.ts +15 -0
  86. package/dist/components/scroll-fade.js +116 -0
  87. package/dist/components/search-bar.d.ts +1 -2
  88. package/dist/components/search-bar.js +37 -60
  89. package/dist/components/searchable-select.js +76 -72
  90. package/dist/components/select.d.ts +5 -1
  91. package/dist/components/select.js +30 -14
  92. package/dist/components/selection-bar.d.ts +11 -0
  93. package/dist/components/selection-bar.js +65 -0
  94. package/dist/components/separator.js +3 -2
  95. package/dist/components/sheet.d.ts +1 -1
  96. package/dist/components/sheet.js +1 -1
  97. package/dist/components/skeleton-card.js +1 -1
  98. package/dist/components/skeleton.js +1 -1
  99. package/dist/components/slider.js +21 -4
  100. package/dist/components/sliding-panels.js +9 -6
  101. package/dist/components/spinner.d.ts +17 -0
  102. package/dist/components/spinner.js +50 -0
  103. package/dist/components/stepper.d.ts +18 -0
  104. package/dist/components/stepper.js +199 -0
  105. package/dist/components/switch.js +4 -3
  106. package/dist/components/table.d.ts +23 -5
  107. package/dist/components/table.js +121 -35
  108. package/dist/components/tabs.d.ts +3 -1
  109. package/dist/components/tabs.js +37 -9
  110. package/dist/components/tag-group.d.ts +9 -0
  111. package/dist/components/tag-group.js +26 -0
  112. package/dist/components/tag.d.ts +17 -0
  113. package/dist/components/tag.js +61 -0
  114. package/dist/components/text.d.ts +1 -1
  115. package/dist/components/text.js +1 -1
  116. package/dist/components/textarea.js +2 -1
  117. package/dist/components/toast.js +1 -1
  118. package/dist/components/toaster.js +1 -1
  119. package/dist/components/toggle-group.js +1 -1
  120. package/dist/components/toggle.js +1 -1
  121. package/dist/components/tooltip.d.ts +3 -2
  122. package/dist/components/tooltip.js +16 -2
  123. package/package.json +45 -26
  124. package/src/components/accordion.tsx +11 -6
  125. package/src/components/alert-dialog.tsx +4 -2
  126. package/src/components/alert.tsx +20 -8
  127. package/src/components/avatar.tsx +106 -13
  128. package/src/components/badge.tsx +12 -11
  129. package/src/components/breadcrumb.tsx +5 -2
  130. package/src/components/button-group.stories.tsx +52 -0
  131. package/src/components/button-group.tsx +40 -0
  132. package/src/components/button.test.tsx +174 -0
  133. package/src/components/button.tsx +42 -19
  134. package/src/components/calendar.tsx +2 -2
  135. package/src/components/card.tsx +55 -13
  136. package/src/components/carousel.tsx +85 -13
  137. package/src/components/checkbox.test.tsx +58 -0
  138. package/src/components/checkbox.tsx +8 -3
  139. package/src/components/circular-progress.stories.tsx +39 -0
  140. package/src/components/circular-progress.tsx +102 -0
  141. package/src/components/collapsible.stories.tsx +7 -3
  142. package/src/components/combobox.stories.tsx +82 -0
  143. package/src/components/combobox.test.tsx +118 -0
  144. package/src/components/combobox.tsx +136 -0
  145. package/src/components/command.test.tsx +91 -0
  146. package/src/components/command.tsx +38 -15
  147. package/src/components/currency-input.test.tsx +63 -0
  148. package/src/components/currency-input.tsx +84 -66
  149. package/src/components/date-input.stories.tsx +31 -0
  150. package/src/components/date-input.tsx +43 -0
  151. package/src/components/description-list.stories.tsx +62 -0
  152. package/src/components/description-list.tsx +68 -0
  153. package/src/components/dialog.test.tsx +84 -0
  154. package/src/components/dialog.tsx +64 -24
  155. package/src/components/drawer.tsx +72 -23
  156. package/src/components/dropdown-menu.test.tsx +180 -0
  157. package/src/components/dropdown-menu.tsx +31 -6
  158. package/src/components/field.stories.tsx +36 -0
  159. package/src/components/field.test.tsx +157 -0
  160. package/src/components/field.tsx +146 -0
  161. package/src/components/form.stories.tsx +3 -2
  162. package/src/components/form.test.tsx +95 -0
  163. package/src/components/form.tsx +9 -0
  164. package/src/components/haptic-button.tsx +1 -1
  165. package/src/components/input-group.stories.tsx +61 -0
  166. package/src/components/input-group.tsx +99 -0
  167. package/src/components/input.test.tsx +103 -0
  168. package/src/components/input.tsx +24 -7
  169. package/src/components/item.stories.tsx +52 -0
  170. package/src/components/item.tsx +115 -0
  171. package/src/components/kbd.stories.tsx +33 -0
  172. package/src/components/kbd.tsx +46 -0
  173. package/src/components/lazy-markdown.tsx +10 -7
  174. package/src/components/multi-select-combobox.test.tsx +101 -0
  175. package/src/components/multi-select-combobox.tsx +85 -82
  176. package/src/components/multi-select-menu.tsx +49 -41
  177. package/src/components/native-select.stories.tsx +52 -0
  178. package/src/components/native-select.tsx +45 -0
  179. package/src/components/pagination.tsx +64 -5
  180. package/src/components/popover.tsx +66 -3
  181. package/src/components/progress.tsx +27 -4
  182. package/src/components/radio-group.test.tsx +64 -0
  183. package/src/components/radio-group.tsx +11 -3
  184. package/src/components/rating.test.tsx +112 -0
  185. package/src/components/rating.tsx +97 -82
  186. package/src/components/scroll-fade.stories.tsx +98 -0
  187. package/src/components/scroll-fade.tsx +127 -0
  188. package/src/components/search-bar.stories.tsx +1 -32
  189. package/src/components/search-bar.tsx +22 -59
  190. package/src/components/searchable-select.tsx +69 -73
  191. package/src/components/select.tsx +34 -19
  192. package/src/components/selection-bar.stories.tsx +37 -0
  193. package/src/components/selection-bar.tsx +65 -0
  194. package/src/components/separator.tsx +5 -2
  195. package/src/components/sheet.tsx +1 -1
  196. package/src/components/skeleton.tsx +2 -2
  197. package/src/components/slider.test.tsx +45 -0
  198. package/src/components/slider.tsx +31 -12
  199. package/src/components/sliding-panels.tsx +8 -5
  200. package/src/components/spinner.stories.tsx +35 -0
  201. package/src/components/spinner.tsx +50 -0
  202. package/src/components/stepper.stories.tsx +71 -0
  203. package/src/components/stepper.tsx +220 -0
  204. package/src/components/switch.test.tsx +52 -0
  205. package/src/components/switch.tsx +4 -3
  206. package/src/components/table.tsx +140 -35
  207. package/src/components/tabs.test.tsx +107 -0
  208. package/src/components/tabs.tsx +70 -23
  209. package/src/components/tag-group.stories.tsx +39 -0
  210. package/src/components/tag-group.tsx +28 -0
  211. package/src/components/tag.stories.tsx +50 -0
  212. package/src/components/tag.tsx +63 -0
  213. package/src/components/text.test.tsx +1 -1
  214. package/src/components/text.tsx +19 -3
  215. package/src/components/textarea.test.tsx +48 -0
  216. package/src/components/textarea.tsx +2 -1
  217. package/src/components/toast.tsx +4 -3
  218. package/src/components/toggle.tsx +2 -2
  219. package/src/components/tooltip.tsx +12 -2
  220. package/src/globals.css +126 -90
  221. package/dist/chunk-2BMJRXQX.js +0 -80
  222. package/dist/chunk-4AVS6CV4.js +0 -89
  223. package/dist/chunk-4UO73SKC.js +0 -26
  224. package/dist/chunk-CW24PLDW.js +0 -24
  225. package/dist/chunk-IQASCH2V.js +0 -25
  226. package/dist/chunk-NKHH23YK.js +0 -31
  227. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -1,20 +1,37 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cn } from "../lib/utils";
5
4
  import { Star, StarHalf } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
6
 
7
7
  interface RatingProps {
8
- value: number; // 1-5
9
- max?: number; // default 5
8
+ value: number;
9
+ max?: number;
10
10
  size?: "sm" | "md" | "lg";
11
11
  showCount?: boolean;
12
12
  reviewCount?: number;
13
13
  readonly?: boolean;
14
14
  onRatingChange?: (rating: number) => void;
15
15
  className?: string;
16
+ /** Accessible label for the rating control. Defaults to "Rating". */
17
+ label?: string;
16
18
  }
17
19
 
20
+ const sizeClasses = {
21
+ sm: "size-3",
22
+ md: "size-4",
23
+ lg: "size-5",
24
+ } as const;
25
+
26
+ const textSizeClasses = {
27
+ sm: "text-xs",
28
+ md: "text-sm",
29
+ lg: "text-base",
30
+ } as const;
31
+
32
+ const FILLED_CLASSES = "fill-amber-400 text-amber-400";
33
+ const EMPTY_CLASSES = "fill-transparent text-muted-foreground/40";
34
+
18
35
  const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
19
36
  (
20
37
  {
@@ -26,103 +43,101 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
26
43
  readonly = true,
27
44
  onRatingChange,
28
45
  className,
46
+ label = "Rating",
29
47
  ...props
30
48
  },
31
49
  ref
32
50
  ) => {
33
51
  const [hoverValue, setHoverValue] = React.useState<number | null>(null);
52
+ const displayValue = hoverValue ?? value;
34
53
 
35
- const sizeClasses = {
36
- sm: "h-3 w-3",
37
- md: "h-4 w-4",
38
- lg: "h-5 w-5",
39
- };
40
-
41
- const textSizeClasses = {
42
- sm: "text-xs",
43
- md: "text-sm",
44
- lg: "text-base",
45
- };
46
-
47
- const handleClick = (rating: number) => {
48
- if (!readonly && onRatingChange) {
49
- onRatingChange(rating);
50
- }
51
- };
54
+ if (readonly) {
55
+ const fullStars = Math.floor(displayValue);
56
+ const hasHalfStar = displayValue % 1 !== 0;
57
+ const emptyStars = max - fullStars - (hasHalfStar ? 1 : 0);
52
58
 
53
- const handleMouseEnter = (rating: number) => {
54
- if (!readonly) {
55
- setHoverValue(rating);
56
- }
57
- };
59
+ return (
60
+ <div
61
+ ref={ref}
62
+ role="img"
63
+ aria-label={`${label}: ${value} out of ${max}`}
64
+ className={cn("flex items-center gap-1", className)}
65
+ {...props}
66
+ >
67
+ <div className="flex items-center gap-0.5">
68
+ {Array.from({ length: fullStars }).map((_, i) => (
69
+ <Star key={`full-${i}`} className={cn(sizeClasses[size], FILLED_CLASSES)} />
70
+ ))}
71
+ {hasHalfStar && <StarHalf className={cn(sizeClasses[size], FILLED_CLASSES)} />}
72
+ {Array.from({ length: emptyStars }).map((_, i) => (
73
+ <Star key={`empty-${i}`} className={cn(sizeClasses[size], EMPTY_CLASSES)} />
74
+ ))}
75
+ </div>
76
+ {showCount && reviewCount !== undefined && (
77
+ <span className={cn("ml-1 text-muted-foreground", textSizeClasses[size])}>
78
+ ({reviewCount})
79
+ </span>
80
+ )}
81
+ </div>
82
+ );
83
+ }
58
84
 
59
- const handleMouseLeave = () => {
60
- if (!readonly) {
61
- setHoverValue(null);
85
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
86
+ if (!onRatingChange) return;
87
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") {
88
+ e.preventDefault();
89
+ onRatingChange(Math.min(max, value + 1));
90
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
91
+ e.preventDefault();
92
+ onRatingChange(Math.max(0, value - 1));
93
+ } else if (e.key === "Home") {
94
+ e.preventDefault();
95
+ onRatingChange(0);
96
+ } else if (e.key === "End") {
97
+ e.preventDefault();
98
+ onRatingChange(max);
62
99
  }
63
100
  };
64
101
 
65
- const displayValue = hoverValue ?? value;
66
- const fullStars = Math.floor(displayValue);
67
- const hasHalfStar = displayValue % 1 !== 0;
68
- const emptyStars = max - fullStars - (hasHalfStar ? 1 : 0);
69
-
70
102
  return (
71
103
  <div
72
104
  ref={ref}
73
- className={cn("flex items-center gap-1", !readonly && "cursor-pointer", className)}
105
+ role="radiogroup"
106
+ aria-label={label}
107
+ onKeyDown={handleKeyDown}
108
+ className={cn("flex items-center gap-1", className)}
74
109
  {...props}
75
110
  >
76
- {/* Stars */}
77
- <div className="flex items-center gap-0.5">
78
- {/* Full stars */}
79
- {Array.from({ length: fullStars }).map((_, i) => (
80
- <Star
81
- key={`full-${i}`}
82
- className={cn(
83
- sizeClasses[size],
84
- "fill-primary text-primary transition-colors",
85
- !readonly && "hover:fill-primary/80"
86
- )}
87
- onClick={() => handleClick(i + 1)}
88
- onMouseEnter={() => handleMouseEnter(i + 1)}
89
- onMouseLeave={handleMouseLeave}
90
- />
91
- ))}
92
-
93
- {/* Half star */}
94
- {hasHalfStar && (
95
- <StarHalf
96
- className={cn(
97
- sizeClasses[size],
98
- "fill-primary text-primary transition-colors",
99
- !readonly && "hover:fill-primary/80"
100
- )}
101
- onClick={() => handleClick(fullStars + 1)}
102
- onMouseEnter={() => handleMouseEnter(fullStars + 1)}
103
- onMouseLeave={handleMouseLeave}
104
- />
105
- )}
106
-
107
- {/* Empty stars */}
108
- {Array.from({ length: emptyStars }).map((_, i) => (
109
- <Star
110
- key={`empty-${i}`}
111
- className={cn(
112
- sizeClasses[size],
113
- "text-muted-foreground/30 transition-colors",
114
- !readonly && "hover:text-primary/50"
115
- )}
116
- onClick={() => handleClick(fullStars + (hasHalfStar ? 1 : 0) + i + 1)}
117
- onMouseEnter={() => handleMouseEnter(fullStars + (hasHalfStar ? 1 : 0) + i + 1)}
118
- onMouseLeave={handleMouseLeave}
119
- />
120
- ))}
111
+ <div className="flex items-center gap-0.5" onMouseLeave={() => setHoverValue(null)}>
112
+ {Array.from({ length: max }).map((_, i) => {
113
+ const starValue = i + 1;
114
+ const isActive = starValue <= displayValue;
115
+ return (
116
+ <button
117
+ key={starValue}
118
+ type="button"
119
+ role="radio"
120
+ aria-checked={value === starValue}
121
+ aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
122
+ onClick={() => onRatingChange?.(starValue)}
123
+ onMouseEnter={() => setHoverValue(starValue)}
124
+ className={cn(
125
+ "rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
126
+ )}
127
+ >
128
+ <Star
129
+ className={cn(
130
+ sizeClasses[size],
131
+ isActive ? FILLED_CLASSES : EMPTY_CLASSES,
132
+ "transition-colors"
133
+ )}
134
+ />
135
+ </button>
136
+ );
137
+ })}
121
138
  </div>
122
-
123
- {/* Review count */}
124
139
  {showCount && reviewCount !== undefined && (
125
- <span className={cn("text-muted-foreground ml-1", textSizeClasses[size])}>
140
+ <span className={cn("ml-1 text-muted-foreground", textSizeClasses[size])}>
126
141
  ({reviewCount})
127
142
  </span>
128
143
  )}
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ScrollFade } from "./scroll-fade";
3
+
4
+ const meta = {
5
+ title: "Components/ScrollFade",
6
+ component: ScrollFade,
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Scroll-position-aware fade overlay. Hides when at the corresponding edge, fades in when there's more content to scroll. Works vertically or horizontally and matches a chosen surface color.",
13
+ },
14
+ },
15
+ },
16
+ } satisfies Meta<typeof ScrollFade>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const longList = Array.from({ length: 30 });
22
+ const wideList = Array.from({ length: 20 });
23
+
24
+ export const Vertical: Story = {
25
+ args: {
26
+ orientation: "vertical",
27
+ fadeColor: "background",
28
+ size: "md",
29
+ },
30
+ render: (args) => (
31
+ <ScrollFade {...args} className="h-[320px] w-[360px] rounded-lg border" scrollClassName="p-4">
32
+ <ul className="space-y-3">
33
+ {longList.map((_, i) => (
34
+ <li key={i} className="text-sm text-muted-foreground">
35
+ Item #{i + 1} — scroll to reveal the fades at the edges.
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ </ScrollFade>
40
+ ),
41
+ };
42
+
43
+ export const Horizontal: Story = {
44
+ args: {
45
+ orientation: "horizontal",
46
+ fadeColor: "background",
47
+ size: "md",
48
+ },
49
+ render: (args) => (
50
+ <ScrollFade {...args} className="w-[480px] rounded-lg border" scrollClassName="flex gap-3 p-4">
51
+ {wideList.map((_, i) => (
52
+ <div
53
+ key={i}
54
+ className="flex h-24 w-32 shrink-0 items-center justify-center rounded-md bg-muted text-sm"
55
+ >
56
+ Card {i + 1}
57
+ </div>
58
+ ))}
59
+ </ScrollFade>
60
+ ),
61
+ };
62
+
63
+ export const InsideCard: Story = {
64
+ args: {
65
+ orientation: "vertical",
66
+ fadeColor: "card",
67
+ size: "md",
68
+ },
69
+ render: (args) => (
70
+ <div className="bg-card text-card-foreground w-[360px] rounded-2xl border shadow-md">
71
+ <div className="border-border border-b p-4 font-semibold">Drawer header</div>
72
+ <ScrollFade {...args} className="h-[280px]" scrollClassName="p-4">
73
+ <ul className="space-y-3">
74
+ {longList.map((_, i) => (
75
+ <li key={i} className="text-sm text-muted-foreground">
76
+ Row #{i + 1} — fades use <code>fadeColor=&quot;card&quot;</code> to match the surface.
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ </ScrollFade>
81
+ </div>
82
+ ),
83
+ };
84
+
85
+ export const NoOverflow: Story = {
86
+ args: {
87
+ orientation: "vertical",
88
+ fadeColor: "background",
89
+ size: "md",
90
+ },
91
+ render: (args) => (
92
+ <ScrollFade {...args} className="h-[200px] w-[320px] rounded-lg border" scrollClassName="p-4">
93
+ <p className="text-sm text-muted-foreground">
94
+ Content fits inside the box — both fades stay hidden.
95
+ </p>
96
+ </ScrollFade>
97
+ ),
98
+ };
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ type Orientation = "vertical" | "horizontal";
7
+ type FadeColor = "background" | "card" | "muted";
8
+ type Size = "sm" | "md" | "lg";
9
+
10
+ interface ScrollFadeProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ orientation?: Orientation;
12
+ fadeColor?: FadeColor;
13
+ size?: Size;
14
+ /** Class names applied to the inner scrollable element (where overflow lives). */
15
+ scrollClassName?: string;
16
+ }
17
+
18
+ // Static maps so Tailwind can statically analyze the class names.
19
+ const fromMap: Record<FadeColor, string> = {
20
+ background: "from-background",
21
+ card: "from-card",
22
+ muted: "from-muted",
23
+ };
24
+
25
+ const sizeMap: Record<Orientation, Record<Size, string>> = {
26
+ vertical: { sm: "h-6", md: "h-12", lg: "h-20" },
27
+ horizontal: { sm: "w-8", md: "w-16", lg: "w-28" },
28
+ };
29
+
30
+ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
31
+ (
32
+ {
33
+ orientation = "vertical",
34
+ fadeColor = "background",
35
+ size = "md",
36
+ className,
37
+ scrollClassName,
38
+ children,
39
+ ...props
40
+ },
41
+ ref
42
+ ) => {
43
+ const innerRef = React.useRef<HTMLDivElement>(null);
44
+ React.useImperativeHandle(ref, () => innerRef.current as HTMLDivElement);
45
+
46
+ const [atStart, setAtStart] = React.useState(true);
47
+ const [atEnd, setAtEnd] = React.useState(true);
48
+
49
+ const update = React.useCallback(() => {
50
+ const el = innerRef.current;
51
+ if (!el) return;
52
+ if (orientation === "vertical") {
53
+ const overflow = el.scrollHeight - el.clientHeight;
54
+ setAtStart(el.scrollTop <= 1);
55
+ setAtEnd(overflow <= 1 || el.scrollTop >= overflow - 1);
56
+ } else {
57
+ const overflow = el.scrollWidth - el.clientWidth;
58
+ setAtStart(el.scrollLeft <= 1);
59
+ setAtEnd(overflow <= 1 || el.scrollLeft >= overflow - 1);
60
+ }
61
+ }, [orientation]);
62
+
63
+ React.useEffect(() => {
64
+ const el = innerRef.current;
65
+ if (!el) return;
66
+ update();
67
+ el.addEventListener("scroll", update, { passive: true });
68
+ const ro = new ResizeObserver(update);
69
+ ro.observe(el);
70
+ return () => {
71
+ el.removeEventListener("scroll", update);
72
+ ro.disconnect();
73
+ };
74
+ }, [update]);
75
+
76
+ const fromClass = fromMap[fadeColor];
77
+ const fadeSize = sizeMap[orientation][size];
78
+
79
+ return (
80
+ <div
81
+ className={cn(
82
+ "relative isolate flex",
83
+ orientation === "vertical" ? "flex-col" : "flex-row",
84
+ className
85
+ )}
86
+ >
87
+ <div
88
+ ref={innerRef}
89
+ className={cn(
90
+ "min-h-0 min-w-0 flex-1",
91
+ orientation === "vertical" ? "overflow-y-auto" : "overflow-x-auto",
92
+ scrollClassName
93
+ )}
94
+ {...props}
95
+ >
96
+ {children}
97
+ </div>
98
+ <div
99
+ aria-hidden
100
+ className={cn(
101
+ "pointer-events-none absolute z-10 transition-opacity duration-300",
102
+ orientation === "vertical"
103
+ ? `inset-x-0 top-0 ${fadeSize} bg-gradient-to-b`
104
+ : `inset-y-0 left-0 ${fadeSize} bg-gradient-to-r`,
105
+ fromClass,
106
+ atStart ? "opacity-0" : "opacity-100"
107
+ )}
108
+ />
109
+ <div
110
+ aria-hidden
111
+ className={cn(
112
+ "pointer-events-none absolute z-10 transition-opacity duration-300",
113
+ orientation === "vertical"
114
+ ? `inset-x-0 bottom-0 ${fadeSize} bg-gradient-to-t`
115
+ : `inset-y-0 right-0 ${fadeSize} bg-gradient-to-l`,
116
+ fromClass,
117
+ atEnd ? "opacity-0" : "opacity-100"
118
+ )}
119
+ />
120
+ </div>
121
+ );
122
+ }
123
+ );
124
+ ScrollFade.displayName = "ScrollFade";
125
+
126
+ export { ScrollFade };
127
+ export type { ScrollFadeProps };
@@ -20,44 +20,13 @@ export const Default: Story = {
20
20
  render: () => <SearchBarDemo />,
21
21
  };
22
22
 
23
- export const Editorial: Story = {
23
+ export const Wide: Story = {
24
24
  render: () => {
25
25
  const [value, setValue] = useState("");
26
26
  return (
27
27
  <SearchBar
28
28
  value={value}
29
29
  onChange={setValue}
30
- variant="editorial"
31
- placeholder="Search..."
32
- className="w-[400px]"
33
- />
34
- );
35
- },
36
- };
37
-
38
- export const Primary: Story = {
39
- render: () => {
40
- const [value, setValue] = useState("");
41
- return (
42
- <SearchBar
43
- value={value}
44
- onChange={setValue}
45
- variant="primary"
46
- placeholder="Search..."
47
- className="w-[400px]"
48
- />
49
- );
50
- },
51
- };
52
-
53
- export const Secondary: Story = {
54
- render: () => {
55
- const [value, setValue] = useState("");
56
- return (
57
- <SearchBar
58
- value={value}
59
- onChange={setValue}
60
- variant="secondary"
61
30
  placeholder="Search..."
62
31
  className="w-[400px]"
63
32
  />
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import type { ChangeEvent } from "react";
4
- import { Card } from "./card";
5
4
  import { Loader2, Search } from "lucide-react";
6
5
  import { cn } from "../lib/utils";
7
6
 
@@ -15,7 +14,6 @@ interface SearchBarProps {
15
14
  autoFocus?: boolean;
16
15
  inputAriaLabel?: string;
17
16
  size?: "default" | "compact";
18
- variant?: "primary" | "secondary" | "editorial";
19
17
  }
20
18
 
21
19
  export function SearchBar({
@@ -28,49 +26,26 @@ export function SearchBar({
28
26
  autoFocus = false,
29
27
  inputAriaLabel,
30
28
  size = "default",
31
- variant = "editorial",
32
29
  }: SearchBarProps) {
33
30
  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
34
- const newValue = e.target.value;
35
- onChange?.(newValue);
31
+ onChange?.(e.target.value);
36
32
  };
37
33
 
38
- const Wrapper = variant === "editorial" ? "div" : Card;
39
- const wrapperClassName =
40
- variant === "editorial"
41
- ? cn(
42
- "bg-surface border border-border rounded-[14px] h-12 flex items-center transition-colors focus-within:border-primary",
43
- className
44
- )
45
- : cn(
46
- "border-2 border-primary-foreground/20 backdrop-blur-xl rounded-lg p-0",
47
- size === "compact" ? "md:p-1.5" : "md:p-2",
48
- variant === "primary"
49
- ? "!bg-primary-foreground/10 !text-primary !border-primary/20"
50
- : "!bg-primary !text-primary-foreground !border-primary/20",
51
- className
52
- );
53
-
54
34
  return (
55
- <Wrapper className={wrapperClassName}>
56
- <div className="flex items-center relative">
57
- <div
35
+ <div
36
+ className={cn(
37
+ "flex w-full items-center rounded-[14px] border border-border bg-surface transition-colors focus-within:border-primary",
38
+ size === "compact" ? "h-10" : "h-12",
39
+ className
40
+ )}
41
+ >
42
+ <div className="relative flex w-full items-center">
43
+ <Search
58
44
  className={cn(
59
- "absolute top-1/2 z-10 -translate-y-1/2",
60
- size === "compact" ? "left-3.5" : "left-4"
45
+ "pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 text-muted-foreground",
46
+ size === "compact" ? "left-3.5 h-3.5 w-3.5" : "left-4 h-4 w-4"
61
47
  )}
62
- >
63
- <Search
64
- className={cn(
65
- variant === "editorial"
66
- ? "text-muted-foreground"
67
- : variant === "primary"
68
- ? "text-primary"
69
- : "text-primary-foreground",
70
- size === "compact" ? "h-3.5 w-3.5" : "h-4 w-4"
71
- )}
72
- />
73
- </div>
48
+ />
74
49
  <input
75
50
  type="text"
76
51
  placeholder={placeholder}
@@ -79,35 +54,23 @@ export function SearchBar({
79
54
  autoFocus={autoFocus}
80
55
  aria-label={inputAriaLabel ?? placeholder}
81
56
  className={cn(
82
- "w-full bg-transparent border-0 focus:outline-none focus-visible:outline-none",
57
+ "w-full border-0 bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none",
83
58
  size === "compact"
84
- ? "pl-9 pr-3 py-2.5 text-sm placeholder:text-sm rounded-lg"
85
- : "pl-10 pr-4 py-3 text-[14px] placeholder:text-[14px] rounded-[14px]",
86
- variant === "editorial"
87
- ? "text-foreground placeholder:text-muted-foreground"
88
- : variant === "primary"
89
- ? "!text-primary placeholder:text-primary/60"
90
- : "!text-primary-foreground placeholder:text-primary-foreground/60",
59
+ ? "rounded-lg py-2.5 pl-9 pr-3 text-base placeholder:text-base md:text-sm md:placeholder:text-sm"
60
+ : "rounded-[14px] py-3 pl-10 pr-4 text-base placeholder:text-base md:text-[14px] md:placeholder:text-[14px]",
91
61
  inputClassName
92
62
  )}
93
63
  />
94
64
  {isSearching && (
95
- <div
65
+ <Loader2
66
+ aria-hidden
96
67
  className={cn(
97
- "absolute top-1/2 -translate-y-1/2",
98
- size === "compact" ? "right-3.5" : "right-4"
68
+ "absolute top-1/2 -translate-y-1/2 animate-spin text-muted-foreground",
69
+ size === "compact" ? "right-3.5 h-3.5 w-3.5" : "right-4 h-4 w-4"
99
70
  )}
100
- >
101
- <Loader2
102
- className={cn(
103
- "animate-spin",
104
- variant === "editorial" ? "text-muted-foreground" : "text-primary/60",
105
- size === "compact" ? "h-3.5 w-3.5" : "h-4 w-4"
106
- )}
107
- />
108
- </div>
71
+ />
109
72
  )}
110
73
  </div>
111
- </Wrapper>
74
+ </div>
112
75
  );
113
76
  }