@cntyclub/ui-react 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cntyclub/ui-react",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "React component library for the Country Club UI Kit — Base UI primitives styled with the Country Club design system (Tailwind CSS v4)",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type * as React from "react";
5
+
6
+ import { cn } from "../../lib/utils/css";
7
+
8
+ interface AnimatedCounterProps extends Omit<React.ComponentProps<"span">, "children"> {
9
+ /** The target value to animate to. */
10
+ value: number;
11
+ /** Tween duration in ms (default 650). */
12
+ duration?: number;
13
+ /** Decimal places to render (default 0). */
14
+ decimals?: number;
15
+ /** Prefix rendered before the number, e.g. "$". */
16
+ prefix?: string;
17
+ /** Suffix rendered after the number, e.g. "%". */
18
+ suffix?: string;
19
+ /** Group digits with thousands separators (default true). */
20
+ separator?: boolean;
21
+ }
22
+
23
+ /**
24
+ * A number that tweens smoothly from its previous value to the next whenever
25
+ * `value` changes — e.g. dashboard stats re-counting when the time range
26
+ * switches. Dependency-free (requestAnimationFrame), so it stays tiny and works
27
+ * anywhere. Respects `prefers-reduced-motion` by snapping to the final value.
28
+ *
29
+ * The animation is purely visual; the accessible text is always the final
30
+ * value (via `aria-label`) so screen readers never read the intermediate tween.
31
+ */
32
+ function AnimatedCounter({
33
+ value,
34
+ duration = 650,
35
+ decimals = 0,
36
+ prefix = "",
37
+ suffix = "",
38
+ separator = true,
39
+ className,
40
+ ...props
41
+ }: AnimatedCounterProps) {
42
+ const [display, setDisplay] = useState(value);
43
+ const fromRef = useRef(value);
44
+ const rafRef = useRef<number | null>(null);
45
+
46
+ useEffect(() => {
47
+ const from = fromRef.current;
48
+ const to = value;
49
+ if (from === to) return;
50
+
51
+ const reduce =
52
+ typeof window !== "undefined" &&
53
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
54
+ if (reduce || duration <= 0) {
55
+ fromRef.current = to;
56
+ setDisplay(to);
57
+ return;
58
+ }
59
+
60
+ let start: number | null = null;
61
+ const step = (ts: number) => {
62
+ if (start === null) start = ts;
63
+ const t = Math.min(1, (ts - start) / duration);
64
+ // easeOutCubic — quick then settles, matching a "count up" feel.
65
+ const eased = 1 - Math.pow(1 - t, 3);
66
+ const current = from + (to - from) * eased;
67
+ setDisplay(current);
68
+ if (t < 1) {
69
+ rafRef.current = requestAnimationFrame(step);
70
+ } else {
71
+ fromRef.current = to;
72
+ }
73
+ };
74
+ rafRef.current = requestAnimationFrame(step);
75
+ return () => {
76
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
77
+ };
78
+ }, [value, duration]);
79
+
80
+ const rounded = Number(display.toFixed(decimals));
81
+ const formatted = separator
82
+ ? rounded.toLocaleString(undefined, {
83
+ minimumFractionDigits: decimals,
84
+ maximumFractionDigits: decimals,
85
+ })
86
+ : rounded.toFixed(decimals);
87
+
88
+ const finalLabel = `${prefix}${value.toLocaleString(undefined, {
89
+ minimumFractionDigits: decimals,
90
+ maximumFractionDigits: decimals,
91
+ })}${suffix}`;
92
+
93
+ return (
94
+ <span
95
+ className={cn("tabular-nums", className)}
96
+ data-slot="animated-counter"
97
+ aria-label={finalLabel}
98
+ {...props}
99
+ >
100
+ <span aria-hidden>
101
+ {prefix}
102
+ {formatted}
103
+ {suffix}
104
+ </span>
105
+ </span>
106
+ );
107
+ }
108
+
109
+ export { AnimatedCounter, type AnimatedCounterProps };
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ import { format } from "date-fns";
4
+ import { CalendarIcon } from "lucide-react";
5
+ import type { DateRange, Matcher } from "react-day-picker";
6
+
7
+ import { Button } from "./button";
8
+ import { Calendar } from "./calendar";
9
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
10
+ import { cn } from "../../lib/utils/css";
11
+
12
+ /** Build react-day-picker `disabled` matchers from optional min/max bounds. */
13
+ function boundsMatcher(fromDate?: Date, toDate?: Date): Matcher[] | undefined {
14
+ const out: Matcher[] = [];
15
+ if (fromDate) out.push({ before: fromDate });
16
+ if (toDate) out.push({ after: toDate });
17
+ return out.length ? out : undefined;
18
+ }
19
+
20
+ interface DatePickerProps {
21
+ /** The selected date (controlled). */
22
+ value?: Date;
23
+ onValueChange?: (date: Date | undefined) => void;
24
+ placeholder?: string;
25
+ /** Trigger button `disabled`. */
26
+ disabled?: boolean;
27
+ /** Restrict selectable days (passed straight to react-day-picker). */
28
+ fromDate?: Date;
29
+ toDate?: Date;
30
+ /** date-fns format for the trigger label (default "LLL dd, y"). */
31
+ displayFormat?: string;
32
+ align?: "start" | "center" | "end";
33
+ className?: string;
34
+ id?: string;
35
+ "aria-label"?: string;
36
+ }
37
+
38
+ /**
39
+ * A single-date picker: a button that opens a calendar in a popover. The
40
+ * canonical way to take a date in this system — never a native `<input
41
+ * type="date">`. Controlled via `value` / `onValueChange`.
42
+ */
43
+ function DatePicker({
44
+ value,
45
+ onValueChange,
46
+ placeholder = "Pick a date",
47
+ disabled,
48
+ fromDate,
49
+ toDate,
50
+ displayFormat = "LLL dd, y",
51
+ align = "start",
52
+ className,
53
+ id,
54
+ ...props
55
+ }: DatePickerProps) {
56
+ return (
57
+ <Popover>
58
+ <PopoverTrigger
59
+ render={
60
+ <Button
61
+ className={cn("w-full justify-start font-normal", !value && "text-muted-foreground", className)}
62
+ disabled={disabled}
63
+ id={id}
64
+ variant="outline"
65
+ aria-label={props["aria-label"]}
66
+ />
67
+ }
68
+ >
69
+ <CalendarIcon aria-hidden />
70
+ {value ? format(value, displayFormat) : <span>{placeholder}</span>}
71
+ </PopoverTrigger>
72
+ <PopoverContent align={align} className="w-auto p-0">
73
+ <Calendar
74
+ autoFocus
75
+ disabled={boundsMatcher(fromDate, toDate)}
76
+ mode="single"
77
+ onSelect={(d) => onValueChange?.(d)}
78
+ selected={value}
79
+ />
80
+ </PopoverContent>
81
+ </Popover>
82
+ );
83
+ }
84
+
85
+ interface DateRangePickerProps {
86
+ value?: DateRange;
87
+ onValueChange?: (range: DateRange | undefined) => void;
88
+ placeholder?: string;
89
+ disabled?: boolean;
90
+ fromDate?: Date;
91
+ toDate?: Date;
92
+ numberOfMonths?: number;
93
+ displayFormat?: string;
94
+ align?: "start" | "center" | "end";
95
+ className?: string;
96
+ id?: string;
97
+ "aria-label"?: string;
98
+ }
99
+
100
+ /**
101
+ * A date-range picker: pick a start and (optionally) end date. Controlled via
102
+ * `value` / `onValueChange` with react-day-picker's `{from, to}` shape.
103
+ */
104
+ function DateRangePicker({
105
+ value,
106
+ onValueChange,
107
+ placeholder = "Pick a date range",
108
+ disabled,
109
+ fromDate,
110
+ toDate,
111
+ numberOfMonths = 2,
112
+ displayFormat = "LLL dd, y",
113
+ align = "start",
114
+ className,
115
+ id,
116
+ ...props
117
+ }: DateRangePickerProps) {
118
+ const label = value?.from
119
+ ? value.to
120
+ ? `${format(value.from, displayFormat)} – ${format(value.to, displayFormat)}`
121
+ : format(value.from, displayFormat)
122
+ : null;
123
+
124
+ return (
125
+ <Popover>
126
+ <PopoverTrigger
127
+ render={
128
+ <Button
129
+ className={cn("w-full justify-start font-normal", !label && "text-muted-foreground", className)}
130
+ disabled={disabled}
131
+ id={id}
132
+ variant="outline"
133
+ aria-label={props["aria-label"]}
134
+ />
135
+ }
136
+ >
137
+ <CalendarIcon aria-hidden />
138
+ {label ?? <span>{placeholder}</span>}
139
+ </PopoverTrigger>
140
+ <PopoverContent align={align} className="w-auto p-0">
141
+ <Calendar
142
+ autoFocus
143
+ disabled={boundsMatcher(fromDate, toDate)}
144
+ mode="range"
145
+ numberOfMonths={numberOfMonths}
146
+ onSelect={(r) => onValueChange?.(r)}
147
+ selected={value}
148
+ />
149
+ </PopoverContent>
150
+ </Popover>
151
+ );
152
+ }
153
+
154
+ export { DatePicker, DateRangePicker, type DatePickerProps, type DateRangePickerProps };
@@ -22,6 +22,17 @@ interface SegmentedControlProps<T extends string> {
22
22
  className?: string;
23
23
  }
24
24
 
25
+ /**
26
+ * Per-size segment padding. The base toggle sizes are icon-button tight
27
+ * (~5px), which crowds text labels like "Past 3 months"; a segmented control
28
+ * reads as text tabs, so it wants room to breathe on both axes.
29
+ */
30
+ const SEGMENT_SIZE: Record<NonNullable<SegmentedControlProps<string>["size"]>, string> = {
31
+ sm: "h-8 px-3.5 sm:h-7",
32
+ default: "h-9 px-4 sm:h-8",
33
+ lg: "h-10 px-5 sm:h-9",
34
+ };
35
+
25
36
  /**
26
37
  * A single-select segmented control — a compact row of mutually-exclusive
27
38
  * options (e.g. a time-range switch: Past month / Past 3 months / Overall).
@@ -53,7 +64,11 @@ function SegmentedControl<T extends string>({
53
64
  variant={variant}
54
65
  >
55
66
  {options.map((option) => (
56
- <ToggleGroupItem key={option.value} value={option.value}>
67
+ <ToggleGroupItem
68
+ key={option.value}
69
+ value={option.value}
70
+ className={SEGMENT_SIZE[size]}
71
+ >
57
72
  {option.label}
58
73
  </ToggleGroupItem>
59
74
  ))}
package/src/index.ts CHANGED
@@ -11,11 +11,13 @@ export * from "./components/ui/aspect-ratio";
11
11
  export * from "./components/ui/autocomplete";
12
12
  export * from "./components/ui/avatar";
13
13
  export * from "./components/ui/avatar-group";
14
+ export * from "./components/ui/animated-counter";
14
15
  export * from "./components/ui/badge";
15
16
  export * from "./components/ui/badge-group";
16
17
  export * from "./components/ui/breadcrumb";
17
18
  export * from "./components/ui/button";
18
19
  export * from "./components/ui/calendar";
20
+ export * from "./components/ui/date-picker";
19
21
  export * from "./components/ui/card";
20
22
  export * from "./components/ui/carousel";
21
23
  export * from "./components/ui/chart";