@brightweblabs/ui 1.4.5 → 1.4.7

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/ui",
3
3
  "private": false,
4
- "version": "1.4.5",
4
+ "version": "1.4.7",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -44,6 +44,7 @@
44
44
  "./popover": "./src/components/popover.tsx",
45
45
  "./role-badge": "./src/components/role-badge.tsx",
46
46
  "./search-field": "./src/components/search-field.tsx",
47
+ "./select": "./src/components/select.tsx",
47
48
  "./section-heading": "./src/components/section-heading.tsx",
48
49
  "./separator": "./src/components/separator.tsx",
49
50
  "./sheet": "./src/components/sheet.tsx",
@@ -66,6 +67,7 @@
66
67
  "@radix-ui/react-dropdown-menu": "^2.1.22",
67
68
  "@radix-ui/react-label": "^2.1.13",
68
69
  "@radix-ui/react-popover": "^1.1.21",
70
+ "@radix-ui/react-select": "^2.3.7",
69
71
  "@radix-ui/react-separator": "^1.1.13",
70
72
  "@radix-ui/react-slot": "^1.3.1",
71
73
  "@radix-ui/react-tooltip": "^1.2.14",
@@ -76,7 +78,7 @@
76
78
  "recharts": "^2.15.4",
77
79
  "sonner": "^2.0.7",
78
80
  "tailwind-merge": "^3.6.0",
79
- "@brightweblabs/theme": "0.7.3"
81
+ "@brightweblabs/theme": "0.7.4"
80
82
  },
81
83
  "peerDependencies": {
82
84
  "lucide-react": "^1.8.0",
@@ -0,0 +1,216 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SelectPrimitive from "@radix-ui/react-select";
5
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
6
+
7
+ import { cn } from "../lib/utils";
8
+
9
+ const Select = SelectPrimitive.Root;
10
+ const SelectGroup = SelectPrimitive.Group;
11
+ const SelectValue = SelectPrimitive.Value;
12
+ const EMPTY_SELECT_VALUE = "__brightweb_empty_select_value__";
13
+
14
+ export type SelectControlOption = {
15
+ value: string;
16
+ label: React.ReactNode;
17
+ disabled?: boolean;
18
+ };
19
+
20
+ export type SelectControlProps = {
21
+ id?: string;
22
+ name?: string;
23
+ value: string;
24
+ options: readonly SelectControlOption[];
25
+ onValueChange: (value: string) => void;
26
+ placeholder?: string;
27
+ disabled?: boolean;
28
+ required?: boolean;
29
+ className?: string;
30
+ contentClassName?: string;
31
+ "aria-label"?: string;
32
+ };
33
+
34
+ export type StyledSelectChangeEvent = {
35
+ target: { value: string };
36
+ currentTarget: { value: string };
37
+ };
38
+
39
+ export type StyledSelectProps = Omit<SelectControlProps, "options" | "onValueChange" | "placeholder"> & {
40
+ children: React.ReactNode;
41
+ onChange?: (event: StyledSelectChangeEvent) => void;
42
+ };
43
+
44
+ function SelectTrigger({
45
+ className,
46
+ children,
47
+ ...props
48
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
49
+ return (
50
+ <SelectPrimitive.Trigger
51
+ data-slot="select-trigger"
52
+ className={cn(
53
+ "flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-[color:var(--sheet-edit-control-border,var(--border))] bg-[color:var(--card)] px-2.5 text-left text-body text-foreground shadow-none outline-none transition-[border-color,box-shadow,background-color] placeholder:text-foreground/45 focus:border-[color:var(--accent)] focus:ring-[3px] focus:ring-[color:var(--project-ui-color-10,var(--ring))] disabled:cursor-not-allowed disabled:opacity-55 [&>span]:min-w-0 [&>span]:truncate",
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ {children}
59
+ <SelectPrimitive.Icon asChild>
60
+ <ChevronDown className="size-4 shrink-0 text-foreground/55 transition-transform duration-150 data-[state=open]:rotate-180" />
61
+ </SelectPrimitive.Icon>
62
+ </SelectPrimitive.Trigger>
63
+ );
64
+ }
65
+
66
+ function SelectContent({
67
+ className,
68
+ children,
69
+ position = "popper",
70
+ sideOffset = 6,
71
+ ...props
72
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
73
+ return (
74
+ <SelectPrimitive.Portal>
75
+ <SelectPrimitive.Content
76
+ data-slot="select-content"
77
+ position={position}
78
+ sideOffset={sideOffset}
79
+ className={cn(
80
+ "relative z-[1400] max-h-[min(20rem,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-xl border border-hairline bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
81
+ className,
82
+ )}
83
+ {...props}
84
+ >
85
+ <SelectPrimitive.ScrollUpButton className="flex h-7 cursor-default items-center justify-center bg-popover text-foreground/60">
86
+ <ChevronUp className="size-4" />
87
+ </SelectPrimitive.ScrollUpButton>
88
+ <SelectPrimitive.Viewport className="p-1.5">{children}</SelectPrimitive.Viewport>
89
+ <SelectPrimitive.ScrollDownButton className="flex h-7 cursor-default items-center justify-center bg-popover text-foreground/60">
90
+ <ChevronDown className="size-4" />
91
+ </SelectPrimitive.ScrollDownButton>
92
+ </SelectPrimitive.Content>
93
+ </SelectPrimitive.Portal>
94
+ );
95
+ }
96
+
97
+ function SelectLabel({
98
+ className,
99
+ ...props
100
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
101
+ return (
102
+ <SelectPrimitive.Label
103
+ className={cn("px-2.5 py-1.5 text-label font-semibold text-muted-foreground", className)}
104
+ {...props}
105
+ />
106
+ );
107
+ }
108
+
109
+ function SelectItem({
110
+ className,
111
+ children,
112
+ ...props
113
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
114
+ return (
115
+ <SelectPrimitive.Item
116
+ data-slot="select-item"
117
+ className={cn(
118
+ "relative flex min-h-9 w-full cursor-default select-none items-center rounded-lg py-2 pl-2.5 pr-9 text-body text-foreground outline-none transition-colors focus:bg-accent/12 data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[state=checked]:bg-accent/10 data-[state=checked]:font-semibold",
119
+ className,
120
+ )}
121
+ {...props}
122
+ >
123
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
124
+ <span className="absolute right-2.5 flex size-4 items-center justify-center text-accent">
125
+ <SelectPrimitive.ItemIndicator>
126
+ <Check className="size-4 stroke-[2.5]" />
127
+ </SelectPrimitive.ItemIndicator>
128
+ </span>
129
+ </SelectPrimitive.Item>
130
+ );
131
+ }
132
+
133
+ function SelectSeparator({
134
+ className,
135
+ ...props
136
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
137
+ return <SelectPrimitive.Separator className={cn("my-1 h-px bg-border", className)} {...props} />;
138
+ }
139
+
140
+ function SelectControl({
141
+ id,
142
+ name,
143
+ value,
144
+ options,
145
+ onValueChange,
146
+ placeholder,
147
+ disabled,
148
+ required,
149
+ className,
150
+ contentClassName,
151
+ "aria-label": ariaLabel,
152
+ }: SelectControlProps) {
153
+ const normalizedValue = value || EMPTY_SELECT_VALUE;
154
+ const normalizedOptions = options.some((option) => option.value === "")
155
+ ? options
156
+ : placeholder
157
+ ? [{ value: "", label: placeholder }, ...options]
158
+ : options;
159
+
160
+ return (
161
+ <Select
162
+ name={name}
163
+ value={normalizedValue}
164
+ onValueChange={(nextValue) => onValueChange(nextValue === EMPTY_SELECT_VALUE ? "" : nextValue)}
165
+ disabled={disabled}
166
+ required={required}
167
+ >
168
+ <SelectTrigger id={id} aria-label={ariaLabel} className={className}>
169
+ <SelectValue />
170
+ </SelectTrigger>
171
+ <SelectContent align="start" className={contentClassName}>
172
+ {normalizedOptions.map((option) => (
173
+ <SelectItem
174
+ key={option.value || EMPTY_SELECT_VALUE}
175
+ value={option.value || EMPTY_SELECT_VALUE}
176
+ disabled={option.disabled}
177
+ >
178
+ {option.label}
179
+ </SelectItem>
180
+ ))}
181
+ </SelectContent>
182
+ </Select>
183
+ );
184
+ }
185
+
186
+ function StyledSelect({ children, onChange, ...props }: StyledSelectProps) {
187
+ const options = React.Children.toArray(children).flatMap((child) => {
188
+ if (!React.isValidElement<{ value?: string | number; children?: React.ReactNode; disabled?: boolean }>(child) || child.type !== "option") return [];
189
+ return [{
190
+ value: child.props.value == null ? "" : String(child.props.value),
191
+ label: child.props.children,
192
+ disabled: child.props.disabled,
193
+ }];
194
+ });
195
+
196
+ return (
197
+ <SelectControl
198
+ {...props}
199
+ options={options}
200
+ onValueChange={(value) => onChange?.({ target: { value }, currentTarget: { value } })}
201
+ />
202
+ );
203
+ }
204
+
205
+ export {
206
+ Select,
207
+ SelectControl,
208
+ SelectContent,
209
+ SelectGroup,
210
+ SelectItem,
211
+ SelectLabel,
212
+ SelectSeparator,
213
+ SelectTrigger,
214
+ SelectValue,
215
+ StyledSelect,
216
+ };
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export { PhoneInput } from "./components/phone-input";
26
26
  export type { PhoneInputProps } from "./components/phone-input";
27
27
  export { SearchField } from "./components/search-field";
28
28
  export type { SearchFieldProps } from "./components/search-field";
29
+ export * from "./components/select";
29
30
  export * from "./components/skeleton";
30
31
  export { TableRowsSkeleton } from "./components/skeleton-table";
31
32
  export { Separator } from "./components/separator";
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+
4
+ import { cn } from "./utils.ts"
5
+
6
+ test("canonical typography utilities do not remove semantic foreground colors", () => {
7
+ const typographyUtilities = [
8
+ "text-heading-1",
9
+ "text-heading-2",
10
+ "text-heading-3",
11
+ "text-heading-4",
12
+ "text-title",
13
+ "text-body-lg",
14
+ "text-body",
15
+ "text-meta",
16
+ "text-label",
17
+ "text-micro",
18
+ "text-kpi",
19
+ "text-kpi-lg",
20
+ "text-data-sm",
21
+ ]
22
+
23
+ for (const typography of typographyUtilities) {
24
+ const result = cn("text-destructive-foreground", typography).split(" ")
25
+
26
+ assert.ok(result.includes(typography), `${typography} must remain a font-size utility`)
27
+ assert.ok(
28
+ result.includes("text-destructive-foreground"),
29
+ `${typography} must not remove semantic foreground colors`,
30
+ )
31
+ }
32
+ })
33
+
34
+ test("canonical typography utilities preserve intentional consumer colors", () => {
35
+ const result = cn("text-label text-muted-foreground", "text-body text-destructive").split(" ")
36
+
37
+ assert.ok(result.includes("text-body"))
38
+ assert.ok(result.includes("text-destructive"))
39
+ assert.ok(!result.includes("text-label"))
40
+ assert.ok(!result.includes("text-muted-foreground"))
41
+ })
package/src/lib/utils.ts CHANGED
@@ -1,5 +1,27 @@
1
1
  import { clsx, type ClassValue } from "clsx"
2
- import { twMerge } from "tailwind-merge"
2
+ import { extendTailwindMerge } from "tailwind-merge"
3
+
4
+ const twMerge = extendTailwindMerge<"canonical-typography">({
5
+ extend: {
6
+ classGroups: {
7
+ "canonical-typography": [
8
+ "text-heading-1",
9
+ "text-heading-2",
10
+ "text-heading-3",
11
+ "text-heading-4",
12
+ "text-title",
13
+ "text-body-lg",
14
+ "text-body",
15
+ "text-meta",
16
+ "text-label",
17
+ "text-micro",
18
+ "text-kpi",
19
+ "text-kpi-lg",
20
+ "text-data-sm",
21
+ ],
22
+ },
23
+ },
24
+ })
3
25
 
4
26
  export function cn(...inputs: ClassValue[]) {
5
27
  return twMerge(clsx(inputs))