@cntyclub/ui-react 0.5.0 → 0.7.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/dist/index.d.ts +67 -2
- package/dist/index.js +211 -110
- package/dist/index.js.map +1 -1
- package/package.json +10 -31
- package/src/components/ui/pipeline.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +68 -0
- package/src/components/ui/sidebar.tsx +33 -2
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cntyclub/ui-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": [
|
|
@@ -28,33 +28,6 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"comment-publishConfig": "In-repo (docs/storybook/vue) consume src/ via the exports above. When PUBLISHED to a registry, npm/pnpm swap in these fields so external installs get the compiled, self-contained dist/ (tsup bundles every dependency except react/react-dom) — no transpilePackages, no heavy transitive dep tree.",
|
|
31
|
-
"publishConfig": {
|
|
32
|
-
"main": "./dist/index.js",
|
|
33
|
-
"module": "./dist/index.js",
|
|
34
|
-
"types": "./dist/index.d.ts",
|
|
35
|
-
"exports": {
|
|
36
|
-
".": {
|
|
37
|
-
"types": "./dist/index.d.ts",
|
|
38
|
-
"import": "./dist/index.js",
|
|
39
|
-
"default": "./dist/index.js"
|
|
40
|
-
},
|
|
41
|
-
"./form": {
|
|
42
|
-
"types": "./dist/form.d.ts",
|
|
43
|
-
"import": "./dist/form.js",
|
|
44
|
-
"default": "./dist/form.js"
|
|
45
|
-
},
|
|
46
|
-
"./styles.css": "./src/styles.css",
|
|
47
|
-
"./package.json": "./package.json"
|
|
48
|
-
},
|
|
49
|
-
"access": "public"
|
|
50
|
-
},
|
|
51
|
-
"scripts": {
|
|
52
|
-
"build": "tsup",
|
|
53
|
-
"dev": "tsup --watch",
|
|
54
|
-
"typecheck": "tsc --noEmit",
|
|
55
|
-
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
|
|
56
|
-
"prepublishOnly": "tsup"
|
|
57
|
-
},
|
|
58
31
|
"dependencies": {
|
|
59
32
|
"@base-ui/react": "1.1.0",
|
|
60
33
|
"@headlessui/react": "^2.2.9",
|
|
@@ -91,12 +64,18 @@
|
|
|
91
64
|
"react-dom": "^19.0.0"
|
|
92
65
|
},
|
|
93
66
|
"devDependencies": {
|
|
94
|
-
"@countryclub/typescript-config": "workspace:*",
|
|
95
67
|
"@types/react": "^19.1.0",
|
|
96
68
|
"@types/react-dom": "^19.1.0",
|
|
97
69
|
"react": "^19.1.0",
|
|
98
70
|
"react-dom": "^19.1.0",
|
|
99
71
|
"tsup": "^8.4.0",
|
|
100
|
-
"typescript": "^5.8.3"
|
|
72
|
+
"typescript": "^5.8.3",
|
|
73
|
+
"@countryclub/typescript-config": "0.1.0"
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"build": "tsup",
|
|
77
|
+
"dev": "tsup --watch",
|
|
78
|
+
"typecheck": "tsc --noEmit",
|
|
79
|
+
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\""
|
|
101
80
|
}
|
|
102
|
-
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
4
|
+
import { Fragment } from "react";
|
|
5
|
+
import type * as React from "react";
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../lib/utils/css";
|
|
8
|
+
|
|
9
|
+
interface PipelineStage {
|
|
10
|
+
/** The headline number for the stage. */
|
|
11
|
+
value: React.ReactNode;
|
|
12
|
+
/** The stage caption, e.g. "Target accounts". */
|
|
13
|
+
label: React.ReactNode;
|
|
14
|
+
/** Render the value in the brand/primary color (e.g. a final stage). */
|
|
15
|
+
accent?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PipelineProps extends React.ComponentProps<"div"> {
|
|
19
|
+
/** Ordered funnel stages, left to right. */
|
|
20
|
+
stages: PipelineStage[];
|
|
21
|
+
/**
|
|
22
|
+
* Conversion markers shown between consecutive stages (length =
|
|
23
|
+
* `stages.length - 1`). Typically a percentage string; any node works.
|
|
24
|
+
*/
|
|
25
|
+
conversions?: React.ReactNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A horizontal conversion funnel — a row of headline stages with the
|
|
30
|
+
* step-to-step conversion shown between them.
|
|
31
|
+
*
|
|
32
|
+
* Self-contained responsive: the component establishes its own container, so
|
|
33
|
+
* it lays out as a horizontal funnel when it has room and stacks vertically
|
|
34
|
+
* when narrow (e.g. beside a docked panel). When stacked, each conversion sits
|
|
35
|
+
* attached to the stage number it converts from; when wide, it sits between
|
|
36
|
+
* the two stages with a chevron.
|
|
37
|
+
*/
|
|
38
|
+
function Pipeline({ stages, conversions = [], className, ...props }: PipelineProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn("@container", className)} data-slot="pipeline" {...props}>
|
|
41
|
+
<div className="flex flex-col gap-1 @min-[640px]:flex-row @min-[640px]:items-start">
|
|
42
|
+
{stages.map((stage, i) => {
|
|
43
|
+
const conv = i < stages.length - 1 ? conversions[i] : undefined;
|
|
44
|
+
return (
|
|
45
|
+
<Fragment key={i}>
|
|
46
|
+
<div
|
|
47
|
+
className="min-w-0 py-1.5 @min-[640px]:flex-1 @min-[640px]:px-2 @min-[640px]:py-0"
|
|
48
|
+
data-slot="pipeline-stage"
|
|
49
|
+
>
|
|
50
|
+
<div className="flex items-baseline gap-2">
|
|
51
|
+
<span
|
|
52
|
+
className={cn(
|
|
53
|
+
"font-semibold text-2xl text-foreground leading-none tabular-nums tracking-tight",
|
|
54
|
+
stage.accent && "text-primary",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{stage.value}
|
|
58
|
+
</span>
|
|
59
|
+
{conv != null ? (
|
|
60
|
+
<PipelineConversion className="@min-[640px]:hidden">
|
|
61
|
+
{conv}
|
|
62
|
+
</PipelineConversion>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
<div className="mt-1 text-muted-foreground text-xs leading-snug">
|
|
66
|
+
{stage.label}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
{conv != null ? (
|
|
70
|
+
<div
|
|
71
|
+
className="hidden flex-col items-center justify-center gap-1 self-center px-1 @min-[640px]:flex"
|
|
72
|
+
data-slot="pipeline-connector"
|
|
73
|
+
>
|
|
74
|
+
<PipelineConversion>{conv}</PipelineConversion>
|
|
75
|
+
<ChevronRightIcon
|
|
76
|
+
className="size-3.5 text-muted-foreground/50"
|
|
77
|
+
strokeWidth={2}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
) : null}
|
|
81
|
+
</Fragment>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The small pill that carries a stage-to-stage conversion value. */
|
|
90
|
+
function PipelineConversion({
|
|
91
|
+
className,
|
|
92
|
+
...props
|
|
93
|
+
}: React.ComponentProps<"span">) {
|
|
94
|
+
return (
|
|
95
|
+
<span
|
|
96
|
+
className={cn(
|
|
97
|
+
"inline-flex items-center rounded-full border border-border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground text-xs tabular-nums",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
data-slot="pipeline-conversion"
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { Pipeline, PipelineConversion, type PipelineProps, type PipelineStage };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
|
|
6
|
+
import { cn } from "../../lib/utils/css";
|
|
7
|
+
|
|
8
|
+
interface SegmentedControlOption<T extends string> {
|
|
9
|
+
value: T;
|
|
10
|
+
label: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SegmentedControlProps<T extends string> {
|
|
14
|
+
/** The currently-selected option value. */
|
|
15
|
+
value: T;
|
|
16
|
+
/** Called with the newly-selected value (never with an empty selection). */
|
|
17
|
+
onValueChange: (value: T) => void;
|
|
18
|
+
/** The mutually-exclusive options, left to right. */
|
|
19
|
+
options: SegmentedControlOption<T>[];
|
|
20
|
+
size?: "sm" | "default" | "lg";
|
|
21
|
+
variant?: "default" | "outline";
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A single-select segmented control — a compact row of mutually-exclusive
|
|
27
|
+
* options (e.g. a time-range switch: Past month / Past 3 months / Overall).
|
|
28
|
+
*
|
|
29
|
+
* A thin, typed wrapper over {@link ToggleGroup} that keeps exactly one option
|
|
30
|
+
* selected — the active segment can't be toggled off into an empty state — and
|
|
31
|
+
* hides Base UI's array-value plumbing. Pass `options`, `value`, and
|
|
32
|
+
* `onValueChange`; the value is a string union you control.
|
|
33
|
+
*/
|
|
34
|
+
function SegmentedControl<T extends string>({
|
|
35
|
+
value,
|
|
36
|
+
onValueChange,
|
|
37
|
+
options,
|
|
38
|
+
size = "sm",
|
|
39
|
+
variant = "outline",
|
|
40
|
+
className,
|
|
41
|
+
}: SegmentedControlProps<T>) {
|
|
42
|
+
return (
|
|
43
|
+
<ToggleGroup
|
|
44
|
+
className={cn(className)}
|
|
45
|
+
data-slot="segmented-control"
|
|
46
|
+
value={[value]}
|
|
47
|
+
onValueChange={(groupValue) => {
|
|
48
|
+
const next = Array.isArray(groupValue) ? groupValue[0] : groupValue;
|
|
49
|
+
// Ignore the empty-array case so a segment always stays selected.
|
|
50
|
+
if (next != null) onValueChange(next as T);
|
|
51
|
+
}}
|
|
52
|
+
size={size}
|
|
53
|
+
variant={variant}
|
|
54
|
+
>
|
|
55
|
+
{options.map((option) => (
|
|
56
|
+
<ToggleGroupItem key={option.value} value={option.value}>
|
|
57
|
+
{option.label}
|
|
58
|
+
</ToggleGroupItem>
|
|
59
|
+
))}
|
|
60
|
+
</ToggleGroup>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
SegmentedControl,
|
|
66
|
+
type SegmentedControlOption,
|
|
67
|
+
type SegmentedControlProps,
|
|
68
|
+
};
|
|
@@ -58,6 +58,7 @@ function SidebarProvider({
|
|
|
58
58
|
defaultOpen = true,
|
|
59
59
|
open: openProp,
|
|
60
60
|
onOpenChange: setOpenProp,
|
|
61
|
+
persistKey,
|
|
61
62
|
className,
|
|
62
63
|
style,
|
|
63
64
|
children,
|
|
@@ -66,6 +67,14 @@ function SidebarProvider({
|
|
|
66
67
|
defaultOpen?: boolean;
|
|
67
68
|
open?: boolean;
|
|
68
69
|
onOpenChange?: (open: boolean) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Remember the expanded/collapsed state in `localStorage` under this key and
|
|
72
|
+
* restore it on the next load (desktop rail only; the mobile sheet is always
|
|
73
|
+
* closed on load). Opt-in — omit to keep the sidebar un-persisted. Ignored in
|
|
74
|
+
* controlled mode (when `open` is provided). The restore runs before first
|
|
75
|
+
* paint, so there's no expand→collapse flash.
|
|
76
|
+
*/
|
|
77
|
+
persistKey?: string;
|
|
69
78
|
}) {
|
|
70
79
|
// Self-contained breakpoint check (md = 48rem). Resolved in a layout effect
|
|
71
80
|
// before the first paint so the sidebar never flashes from mobile to desktop.
|
|
@@ -93,16 +102,38 @@ function SidebarProvider({
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Persist the sidebar state. document.cookie works in every browser
|
|
96
|
-
// (cookieStore is Chromium-only and throws elsewhere).
|
|
105
|
+
// (cookieStore is Chromium-only and throws elsewhere). When `persistKey`
|
|
106
|
+
// is set we also mirror it to localStorage so a client-only app (no SSR
|
|
107
|
+
// cookie read) can restore it on load.
|
|
97
108
|
try {
|
|
98
109
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
110
|
+
if (persistKey) {
|
|
111
|
+
window.localStorage.setItem(persistKey, openState ? "true" : "false");
|
|
112
|
+
}
|
|
99
113
|
} catch {
|
|
100
114
|
// Ignore — persistence is best-effort.
|
|
101
115
|
}
|
|
102
116
|
},
|
|
103
|
-
[setOpenProp, open],
|
|
117
|
+
[setOpenProp, open, persistKey],
|
|
104
118
|
);
|
|
105
119
|
|
|
120
|
+
// Restore the persisted (localStorage) open state on mount — uncontrolled use
|
|
121
|
+
// only. Runs in a layout effect so the restore lands before first paint (no
|
|
122
|
+
// expand→collapse flash). SSR renders `defaultOpen` and the client's first
|
|
123
|
+
// render matches it, so this reconciles afterwards without a hydration
|
|
124
|
+
// mismatch.
|
|
125
|
+
useIsomorphicLayoutEffect(() => {
|
|
126
|
+
if (!persistKey || openProp !== undefined) return;
|
|
127
|
+
try {
|
|
128
|
+
const stored = window.localStorage.getItem(persistKey);
|
|
129
|
+
if (stored === "true" || stored === "false") {
|
|
130
|
+
_setOpen(stored === "true");
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore — restore is best-effort.
|
|
134
|
+
}
|
|
135
|
+
}, [persistKey, openProp]);
|
|
136
|
+
|
|
106
137
|
// Helper to toggle the sidebar.
|
|
107
138
|
const toggleSidebar = React.useCallback(() => {
|
|
108
139
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,8 @@ export * from "./components/ui/section-header";
|
|
|
68
68
|
export * from "./components/ui/smooth-scroll";
|
|
69
69
|
export * from "./components/ui/social-button";
|
|
70
70
|
export * from "./components/ui/stepper";
|
|
71
|
+
export * from "./components/ui/pipeline";
|
|
72
|
+
export * from "./components/ui/segmented-control";
|
|
71
73
|
export * from "./components/ui/select";
|
|
72
74
|
export * from "./components/ui/separator";
|
|
73
75
|
export * from "./components/ui/sheet";
|