@stasho/ds 0.1.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 +88 -0
- package/src/components/alert/alert.tsx +204 -0
- package/src/components/badge/badge.tsx +139 -0
- package/src/components/breadcrumb/breadcrumb.tsx +130 -0
- package/src/components/button/button.tsx +153 -0
- package/src/components/card/card.tsx +48 -0
- package/src/components/checkbox/checkbox.tsx +82 -0
- package/src/components/combobox/combobox.tsx +166 -0
- package/src/components/copyable-text/copyable-text.tsx +194 -0
- package/src/components/dialog/dialog.tsx +145 -0
- package/src/components/form-field/form-field.tsx +71 -0
- package/src/components/input/input.tsx +52 -0
- package/src/components/logo/logo.tsx +68 -0
- package/src/components/multi-select/multi-select.tsx +312 -0
- package/src/components/pagination/pagination.tsx +218 -0
- package/src/components/progress-bar/progress-bar.tsx +134 -0
- package/src/components/radio-group/radio-group.tsx +87 -0
- package/src/components/select/select.tsx +124 -0
- package/src/components/slider/slider.tsx +127 -0
- package/src/components/status-dot/status-dot.tsx +53 -0
- package/src/components/stepper/stepper.tsx +229 -0
- package/src/components/switch/switch.tsx +74 -0
- package/src/components/table/table.tsx +260 -0
- package/src/components/tabs/tabs.tsx +493 -0
- package/src/components/textarea/textarea.tsx +56 -0
- package/src/components/tooltip/tooltip.tsx +35 -0
- package/src/components/ui/skeleton.tsx +21 -0
- package/src/components/ui/spinner.tsx +27 -0
- package/src/lib/cn.ts +6 -0
- package/src/styles/tokens.css +459 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
type ComponentPropsWithoutRef,
|
|
6
|
+
type HTMLAttributes,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
9
|
+
import { X } from "@phosphor-icons/react";
|
|
10
|
+
import { cn } from "@ac/lib/cn";
|
|
11
|
+
|
|
12
|
+
/* ── Direct re-exports ─────────────────────────── */
|
|
13
|
+
|
|
14
|
+
const Dialog = DialogPrimitive.Root;
|
|
15
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
16
|
+
const DialogClose = DialogPrimitive.Close;
|
|
17
|
+
|
|
18
|
+
/* ── DialogContent (Portal + Overlay + Content) ── */
|
|
19
|
+
|
|
20
|
+
type DialogContentProps = ComponentPropsWithoutRef<
|
|
21
|
+
typeof DialogPrimitive.Content
|
|
22
|
+
> & {
|
|
23
|
+
locked?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
|
27
|
+
({ className, children, locked, ...rest }, ref) => (
|
|
28
|
+
<DialogPrimitive.Portal>
|
|
29
|
+
<DialogPrimitive.Overlay
|
|
30
|
+
className={cn(
|
|
31
|
+
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
|
32
|
+
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
33
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
|
34
|
+
"motion-reduce:animate-none",
|
|
35
|
+
)}
|
|
36
|
+
/>
|
|
37
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
38
|
+
<DialogPrimitive.Content
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn(
|
|
41
|
+
"relative w-full max-w-md rounded-md bg-surface p-6 shadow-brand-lg",
|
|
42
|
+
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
43
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
44
|
+
"motion-reduce:animate-none",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
{...(locked
|
|
48
|
+
? {
|
|
49
|
+
onInteractOutside: (e: Event) => e.preventDefault(),
|
|
50
|
+
onEscapeKeyDown: (e: KeyboardEvent) => e.preventDefault(),
|
|
51
|
+
}
|
|
52
|
+
: {})}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
{locked ? null : (
|
|
57
|
+
<DialogPrimitive.Close
|
|
58
|
+
className={cn(
|
|
59
|
+
"absolute top-4 right-4 rounded-sm",
|
|
60
|
+
"text-muted-foreground transition-colors hover:text-foreground",
|
|
61
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
62
|
+
"focus-visible:ring-primary-400 focus-visible:ring-offset-2",
|
|
63
|
+
)}
|
|
64
|
+
aria-label="Close"
|
|
65
|
+
>
|
|
66
|
+
<X weight="bold" className="size-4" />
|
|
67
|
+
</DialogPrimitive.Close>
|
|
68
|
+
)}
|
|
69
|
+
</DialogPrimitive.Content>
|
|
70
|
+
</div>
|
|
71
|
+
</DialogPrimitive.Portal>
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
DialogContent.displayName = "DialogContent";
|
|
76
|
+
|
|
77
|
+
/* ── DialogTitle ───────────────────────────────── */
|
|
78
|
+
|
|
79
|
+
const DialogTitle = forwardRef<
|
|
80
|
+
HTMLHeadingElement,
|
|
81
|
+
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
82
|
+
>(({ className, ...rest }, ref) => (
|
|
83
|
+
<DialogPrimitive.Title
|
|
84
|
+
ref={ref}
|
|
85
|
+
className={cn(
|
|
86
|
+
"font-heading font-bold text-lg text-foreground",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...rest}
|
|
90
|
+
/>
|
|
91
|
+
));
|
|
92
|
+
|
|
93
|
+
DialogTitle.displayName = "DialogTitle";
|
|
94
|
+
|
|
95
|
+
/* ── DialogDescription ─────────────────────────── */
|
|
96
|
+
|
|
97
|
+
const DialogDescription = forwardRef<
|
|
98
|
+
HTMLParagraphElement,
|
|
99
|
+
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
100
|
+
>(({ className, ...rest }, ref) => (
|
|
101
|
+
<DialogPrimitive.Description
|
|
102
|
+
ref={ref}
|
|
103
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
104
|
+
{...rest}
|
|
105
|
+
/>
|
|
106
|
+
));
|
|
107
|
+
|
|
108
|
+
DialogDescription.displayName = "DialogDescription";
|
|
109
|
+
|
|
110
|
+
/* ── Layout helpers (plain divs) ───────────────── */
|
|
111
|
+
|
|
112
|
+
function DialogHeader({
|
|
113
|
+
className,
|
|
114
|
+
...rest
|
|
115
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("flex flex-col gap-1.5", className)} {...rest} />
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function DialogFooter({
|
|
122
|
+
className,
|
|
123
|
+
...rest
|
|
124
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
className={cn("flex justify-end gap-3 pt-4", className)}
|
|
128
|
+
{...rest}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── Exports ───────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
export {
|
|
136
|
+
Dialog,
|
|
137
|
+
DialogClose,
|
|
138
|
+
DialogContent,
|
|
139
|
+
DialogDescription,
|
|
140
|
+
DialogFooter,
|
|
141
|
+
DialogHeader,
|
|
142
|
+
DialogTitle,
|
|
143
|
+
DialogTrigger,
|
|
144
|
+
type DialogContentProps,
|
|
145
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useId,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { cn } from "@ac/lib/cn";
|
|
9
|
+
|
|
10
|
+
type FormFieldProps = {
|
|
11
|
+
label: string;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
helperText?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function FormField({
|
|
20
|
+
label,
|
|
21
|
+
required = false,
|
|
22
|
+
helperText,
|
|
23
|
+
error,
|
|
24
|
+
children,
|
|
25
|
+
className,
|
|
26
|
+
}: FormFieldProps) {
|
|
27
|
+
const id = useId();
|
|
28
|
+
const inputId = `${id}-input`;
|
|
29
|
+
const messageId = `${id}-message`;
|
|
30
|
+
const hasMessage = Boolean(error ?? helperText);
|
|
31
|
+
|
|
32
|
+
const hasError = Boolean(error);
|
|
33
|
+
|
|
34
|
+
const child = isValidElement(children)
|
|
35
|
+
? cloneElement(children as ReactElement<Record<string, unknown>>, {
|
|
36
|
+
id: inputId,
|
|
37
|
+
...(hasMessage ? { "aria-describedby": messageId } : {}),
|
|
38
|
+
...(hasError ? { error: true, "aria-invalid": true } : {}),
|
|
39
|
+
})
|
|
40
|
+
: children;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
44
|
+
<label
|
|
45
|
+
htmlFor={inputId}
|
|
46
|
+
className="text-sm font-medium text-foreground"
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
{required && (
|
|
50
|
+
<span className="text-error-600 ml-0.5" aria-hidden="true">
|
|
51
|
+
*
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
</label>
|
|
55
|
+
{child}
|
|
56
|
+
{error ? (
|
|
57
|
+
<p id={messageId} role="alert" className="text-xs text-error-600">
|
|
58
|
+
{error}
|
|
59
|
+
</p>
|
|
60
|
+
) : helperText ? (
|
|
61
|
+
<p id={messageId} className="text-xs text-muted-foreground">
|
|
62
|
+
{helperText}
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
FormField.displayName = "FormField";
|
|
70
|
+
|
|
71
|
+
export { FormField, type FormFieldProps };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { forwardRef, type InputHTMLAttributes } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@ac/lib/cn";
|
|
4
|
+
|
|
5
|
+
const inputVariants = cva(
|
|
6
|
+
[
|
|
7
|
+
"w-full font-sans text-foreground bg-primary-100 dark:bg-base-700",
|
|
8
|
+
"border-0 rounded-full",
|
|
9
|
+
"placeholder:text-muted-foreground",
|
|
10
|
+
"focus-visible:outline-none focus-visible:ring-3",
|
|
11
|
+
"focus-visible:ring-primary-500",
|
|
12
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
13
|
+
"ring-0 transition-colors",
|
|
14
|
+
].join(" "),
|
|
15
|
+
{
|
|
16
|
+
variants: {
|
|
17
|
+
size: {
|
|
18
|
+
sm: "py-1.5 px-4 text-sm",
|
|
19
|
+
md: "py-2 px-5 text-base",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
size: "md",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> &
|
|
29
|
+
VariantProps<typeof inputVariants> & {
|
|
30
|
+
error?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
34
|
+
({ size, error = false, className, ...rest }, ref) => {
|
|
35
|
+
return (
|
|
36
|
+
<input
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={cn(
|
|
39
|
+
inputVariants({ size }),
|
|
40
|
+
error && "border-3 border-error-400 hover:border-error-500",
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
aria-invalid={error || undefined}
|
|
44
|
+
{...rest}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
Input.displayName = "Input";
|
|
51
|
+
|
|
52
|
+
export { Input, inputVariants, type InputProps };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@ac/lib/cn";
|
|
4
|
+
|
|
5
|
+
type LogoProps = Omit<ComponentPropsWithoutRef<"svg">, "viewBox" | "xmlns">;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* stasho icon mark (two circles + two arcs).
|
|
9
|
+
* Inherits color from parent via `currentColor`.
|
|
10
|
+
*/
|
|
11
|
+
const Logo = forwardRef<SVGSVGElement, LogoProps>(
|
|
12
|
+
({ className, ...rest }, ref) => (
|
|
13
|
+
<svg
|
|
14
|
+
ref={ref}
|
|
15
|
+
viewBox="0 0 209 209"
|
|
16
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
17
|
+
fill="currentColor"
|
|
18
|
+
className={cn("shrink-0", className)}
|
|
19
|
+
{...rest}
|
|
20
|
+
>
|
|
21
|
+
<path d="M170.448 76.895c21.371 0 38.552-17.181 38.552-38.447C209 17.18 191.714 0 170.448 0c-21.372 0-38.552 17.181-38.552 38.448 0 21.266 17.18 38.447 38.552 38.447Z" />
|
|
22
|
+
<path d="M38.553 208.057c21.371 0 38.552-17.181 38.552-38.448 0-21.267-17.286-38.448-38.552-38.448C17.181 131.161 0 148.342 0 169.609c-.104 21.267 17.182 38.448 38.553 38.448Z" />
|
|
23
|
+
<path d="M143.106 11.314C106.544-3.772 62.858 3.457 33.106 33 3.353 62.647-3.875 106.019 11.21 142.476L143.106 11.314Z" />
|
|
24
|
+
<path d="M65.792 196.847c36.562 15.086 80.247 7.857 110-21.686 29.752-29.647 36.98-73.018 21.895-109.475L65.792 196.847Z" />
|
|
25
|
+
</svg>
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
Logo.displayName = "Logo";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* stasho full logo (icon + "stasho" wordmark).
|
|
33
|
+
* Inherits color from parent via `currentColor`.
|
|
34
|
+
*/
|
|
35
|
+
const LogoFull = forwardRef<SVGSVGElement, LogoProps>(
|
|
36
|
+
({ className, ...rest }, ref) => (
|
|
37
|
+
<svg
|
|
38
|
+
ref={ref}
|
|
39
|
+
viewBox="0 0 1383 229"
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
fill="currentColor"
|
|
42
|
+
className={cn("shrink-0", className)}
|
|
43
|
+
{...rest}
|
|
44
|
+
>
|
|
45
|
+
{/* Wordmark: "stasho" (placeholder — visual rebrand deferred) */}
|
|
46
|
+
<text
|
|
47
|
+
x="261"
|
|
48
|
+
y="180"
|
|
49
|
+
fill="currentColor"
|
|
50
|
+
fontFamily="inherit"
|
|
51
|
+
fontSize="200"
|
|
52
|
+
fontWeight="600"
|
|
53
|
+
>
|
|
54
|
+
stasho
|
|
55
|
+
</text>
|
|
56
|
+
{/* Icon mark */}
|
|
57
|
+
<path d="M170.448 76.895c21.371 0 38.552-17.181 38.552-38.447C209 17.18 191.714 0 170.448 0c-21.372 0-38.552 17.181-38.552 38.448 0 21.266 17.18 38.447 38.552 38.447Z" />
|
|
58
|
+
<path d="M38.553 208.056c21.371 0 38.552-17.18 38.552-38.447 0-21.267-17.286-38.448-38.552-38.448C17.181 131.161 0 148.342 0 169.609c-.104 21.267 17.182 38.447 38.553 38.447Z" />
|
|
59
|
+
<path d="M143.106 11.314C106.544-3.772 62.858 3.457 33.106 33 3.353 62.647-3.875 106.019 11.21 142.476L143.106 11.314Z" />
|
|
60
|
+
<path d="M65.792 196.847c36.562 15.086 80.247 7.857 110-21.686 29.752-29.647 36.98-73.018 21.895-109.475L65.792 196.847Z" />
|
|
61
|
+
</svg>
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
LogoFull.displayName = "LogoFull";
|
|
66
|
+
|
|
67
|
+
export { Logo, LogoFull };
|
|
68
|
+
export type { LogoProps };
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { forwardRef, useState } from "react";
|
|
2
|
+
import { Popover } from "radix-ui";
|
|
3
|
+
import { Command } from "cmdk";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { CaretDown, Check, X } from "@phosphor-icons/react";
|
|
6
|
+
import { cn } from "@ac/lib/cn";
|
|
7
|
+
|
|
8
|
+
const triggerVariants = cva(
|
|
9
|
+
[
|
|
10
|
+
"inline-flex items-center gap-1.5",
|
|
11
|
+
"w-full font-sans text-foreground bg-primary-100 dark:bg-base-700",
|
|
12
|
+
"border-0 rounded-2xl",
|
|
13
|
+
"focus-visible:outline-none focus-visible:ring-3",
|
|
14
|
+
"focus-visible:ring-primary-500",
|
|
15
|
+
"aria-disabled:opacity-50 aria-disabled:pointer-events-none",
|
|
16
|
+
"ring-0 transition-colors",
|
|
17
|
+
].join(" "),
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
size: {
|
|
21
|
+
sm: "min-h-9 py-1 px-3 text-sm",
|
|
22
|
+
md: "min-h-11 py-1.5 px-4 text-base",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
size: "md",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const tagVariants = cva(
|
|
32
|
+
[
|
|
33
|
+
"inline-flex items-center gap-1 rounded-full bg-muted",
|
|
34
|
+
"text-foreground max-w-32 select-none",
|
|
35
|
+
].join(" "),
|
|
36
|
+
{
|
|
37
|
+
variants: {
|
|
38
|
+
size: {
|
|
39
|
+
sm: "px-2 py-0.5 text-xs",
|
|
40
|
+
md: "px-2.5 py-0.5 text-sm",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
defaultVariants: {
|
|
44
|
+
size: "md",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
type MultiSelectOption = {
|
|
50
|
+
value: string;
|
|
51
|
+
label: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type MultiSelectProps = VariantProps<typeof triggerVariants> & {
|
|
56
|
+
options: MultiSelectOption[];
|
|
57
|
+
value?: string[];
|
|
58
|
+
onValueChange?: (value: string[]) => void;
|
|
59
|
+
placeholder?: string;
|
|
60
|
+
searchPlaceholder?: string;
|
|
61
|
+
emptyMessage?: string;
|
|
62
|
+
maxDisplayedTags?: number;
|
|
63
|
+
size?: "sm" | "md";
|
|
64
|
+
error?: boolean;
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
className?: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
"aria-describedby"?: string;
|
|
69
|
+
"aria-invalid"?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
73
|
+
(
|
|
74
|
+
{
|
|
75
|
+
options,
|
|
76
|
+
value = [],
|
|
77
|
+
onValueChange,
|
|
78
|
+
placeholder = "Select...",
|
|
79
|
+
searchPlaceholder = "Search...",
|
|
80
|
+
emptyMessage = "No results found.",
|
|
81
|
+
maxDisplayedTags = 2,
|
|
82
|
+
size,
|
|
83
|
+
error = false,
|
|
84
|
+
disabled = false,
|
|
85
|
+
className,
|
|
86
|
+
id,
|
|
87
|
+
"aria-describedby": ariaDescribedBy,
|
|
88
|
+
...rest
|
|
89
|
+
},
|
|
90
|
+
ref,
|
|
91
|
+
) => {
|
|
92
|
+
const [open, setOpen] = useState(false);
|
|
93
|
+
const [search, setSearch] = useState("");
|
|
94
|
+
const hasSelection = value.length > 0;
|
|
95
|
+
|
|
96
|
+
const selectedOptions = options.filter((o) =>
|
|
97
|
+
value.includes(o.value),
|
|
98
|
+
);
|
|
99
|
+
const displayedTags = selectedOptions.slice(0, maxDisplayedTags);
|
|
100
|
+
const overflowCount =
|
|
101
|
+
selectedOptions.length - displayedTags.length;
|
|
102
|
+
|
|
103
|
+
function toggle(optionValue: string) {
|
|
104
|
+
if (!onValueChange) return;
|
|
105
|
+
const next = value.includes(optionValue)
|
|
106
|
+
? value.filter((v) => v !== optionValue)
|
|
107
|
+
: [...value, optionValue];
|
|
108
|
+
onValueChange(next);
|
|
109
|
+
setSearch("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function removeTag(
|
|
113
|
+
e: React.MouseEvent,
|
|
114
|
+
optionValue: string,
|
|
115
|
+
) {
|
|
116
|
+
e.stopPropagation();
|
|
117
|
+
onValueChange?.(value.filter((v) => v !== optionValue));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clearAll(e: React.MouseEvent) {
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
onValueChange?.([]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
127
|
+
<Popover.Trigger asChild>
|
|
128
|
+
<div
|
|
129
|
+
ref={ref}
|
|
130
|
+
id={id}
|
|
131
|
+
role="button"
|
|
132
|
+
tabIndex={disabled ? -1 : 0}
|
|
133
|
+
aria-disabled={disabled || undefined}
|
|
134
|
+
aria-describedby={ariaDescribedBy}
|
|
135
|
+
aria-invalid={error || undefined}
|
|
136
|
+
className={cn(
|
|
137
|
+
triggerVariants({ size }),
|
|
138
|
+
"cursor-pointer",
|
|
139
|
+
error &&
|
|
140
|
+
"border-3 border-error-400 hover:border-error-500",
|
|
141
|
+
!hasSelection && "text-muted-foreground",
|
|
142
|
+
className,
|
|
143
|
+
)}
|
|
144
|
+
{...rest}
|
|
145
|
+
>
|
|
146
|
+
{hasSelection ? (
|
|
147
|
+
<>
|
|
148
|
+
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
|
149
|
+
{displayedTags.map((opt) => (
|
|
150
|
+
<span
|
|
151
|
+
key={opt.value}
|
|
152
|
+
className={cn(tagVariants({ size }), "shrink-0")}
|
|
153
|
+
>
|
|
154
|
+
<span className="truncate">{opt.label}</span>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
aria-label={`Remove ${opt.label}`}
|
|
158
|
+
onClick={(e) => removeTag(e, opt.value)}
|
|
159
|
+
className={cn(
|
|
160
|
+
"shrink-0 rounded-full",
|
|
161
|
+
"hover:bg-foreground/10 transition-colors",
|
|
162
|
+
size === "sm" ? "size-3.5" : "size-4",
|
|
163
|
+
)}
|
|
164
|
+
tabIndex={-1}
|
|
165
|
+
>
|
|
166
|
+
<X
|
|
167
|
+
weight="bold"
|
|
168
|
+
className="size-full"
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
/>
|
|
171
|
+
</button>
|
|
172
|
+
</span>
|
|
173
|
+
))}
|
|
174
|
+
{overflowCount > 0 && (
|
|
175
|
+
<span
|
|
176
|
+
className={cn(
|
|
177
|
+
"text-muted-foreground shrink-0",
|
|
178
|
+
size === "sm" ? "text-xs" : "text-sm",
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
+{overflowCount} more
|
|
182
|
+
</span>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
aria-label="Clear all"
|
|
188
|
+
onClick={clearAll}
|
|
189
|
+
className={cn(
|
|
190
|
+
"shrink-0 rounded-full",
|
|
191
|
+
"text-muted-foreground",
|
|
192
|
+
"hover:text-foreground transition-colors",
|
|
193
|
+
size === "sm" ? "size-4" : "size-5",
|
|
194
|
+
)}
|
|
195
|
+
tabIndex={-1}
|
|
196
|
+
>
|
|
197
|
+
<X
|
|
198
|
+
weight="bold"
|
|
199
|
+
className="size-full"
|
|
200
|
+
aria-hidden="true"
|
|
201
|
+
/>
|
|
202
|
+
</button>
|
|
203
|
+
</>
|
|
204
|
+
) : (
|
|
205
|
+
<>
|
|
206
|
+
<span className="truncate flex-1 text-left">
|
|
207
|
+
{placeholder}
|
|
208
|
+
</span>
|
|
209
|
+
<CaretDown
|
|
210
|
+
weight="bold"
|
|
211
|
+
className={cn(
|
|
212
|
+
"size-4 shrink-0 text-muted-foreground",
|
|
213
|
+
"transition-transform",
|
|
214
|
+
"motion-reduce:transition-none",
|
|
215
|
+
open && "rotate-180",
|
|
216
|
+
)}
|
|
217
|
+
aria-hidden="true"
|
|
218
|
+
/>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</Popover.Trigger>
|
|
223
|
+
<Popover.Portal>
|
|
224
|
+
<Popover.Content
|
|
225
|
+
className={cn(
|
|
226
|
+
"z-50 w-[var(--radix-popover-trigger-width)]",
|
|
227
|
+
"overflow-hidden rounded-2xl",
|
|
228
|
+
"bg-surface border border-edge shadow-brand",
|
|
229
|
+
)}
|
|
230
|
+
sideOffset={4}
|
|
231
|
+
align="start"
|
|
232
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
233
|
+
>
|
|
234
|
+
<Command shouldFilter>
|
|
235
|
+
<Command.Input
|
|
236
|
+
placeholder={searchPlaceholder}
|
|
237
|
+
value={search}
|
|
238
|
+
onValueChange={setSearch}
|
|
239
|
+
className={cn(
|
|
240
|
+
"w-full border-b border-edge bg-transparent",
|
|
241
|
+
"px-4 py-2.5",
|
|
242
|
+
"text-sm text-foreground",
|
|
243
|
+
"placeholder:text-muted-foreground",
|
|
244
|
+
"outline-none",
|
|
245
|
+
)}
|
|
246
|
+
/>
|
|
247
|
+
<Command.List className="max-h-60 overflow-y-auto p-1">
|
|
248
|
+
<Command.Empty className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
249
|
+
{emptyMessage}
|
|
250
|
+
</Command.Empty>
|
|
251
|
+
{options.map((option) => {
|
|
252
|
+
const selected = value.includes(option.value);
|
|
253
|
+
return (
|
|
254
|
+
<Command.Item
|
|
255
|
+
key={option.value}
|
|
256
|
+
value={option.label}
|
|
257
|
+
{...(option.disabled
|
|
258
|
+
? { disabled: true }
|
|
259
|
+
: {})}
|
|
260
|
+
onSelect={() => toggle(option.value)}
|
|
261
|
+
className={cn(
|
|
262
|
+
"relative flex items-center gap-2",
|
|
263
|
+
"rounded-xl px-3 py-2",
|
|
264
|
+
"text-sm text-foreground",
|
|
265
|
+
"cursor-pointer select-none",
|
|
266
|
+
"outline-none",
|
|
267
|
+
"data-[selected=true]:bg-muted",
|
|
268
|
+
"data-[disabled=true]:opacity-50",
|
|
269
|
+
"data-[disabled=true]:pointer-events-none",
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<span
|
|
273
|
+
className={cn(
|
|
274
|
+
"flex size-4 shrink-0 items-center",
|
|
275
|
+
"justify-center",
|
|
276
|
+
"rounded border-2 transition-colors",
|
|
277
|
+
selected
|
|
278
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
279
|
+
: "border-edge bg-surface",
|
|
280
|
+
)}
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
>
|
|
283
|
+
{selected && (
|
|
284
|
+
<Check
|
|
285
|
+
weight="bold"
|
|
286
|
+
className="size-3"
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
</span>
|
|
290
|
+
<span className="flex-1">
|
|
291
|
+
{option.label}
|
|
292
|
+
</span>
|
|
293
|
+
</Command.Item>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</Command.List>
|
|
297
|
+
</Command>
|
|
298
|
+
</Popover.Content>
|
|
299
|
+
</Popover.Portal>
|
|
300
|
+
</Popover.Root>
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
MultiSelect.displayName = "MultiSelect";
|
|
306
|
+
|
|
307
|
+
export {
|
|
308
|
+
MultiSelect,
|
|
309
|
+
triggerVariants,
|
|
310
|
+
type MultiSelectProps,
|
|
311
|
+
type MultiSelectOption,
|
|
312
|
+
};
|