@xenide-io/the-old-ui-theme 0.2.9 → 0.3.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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-SHF57J7U.mjs → chunk-FDMD3IIN.mjs} +1573 -652
  3. package/dist/index-D1RTT2Ap.d.mts +1044 -0
  4. package/dist/index-D1RTT2Ap.d.ts +1044 -0
  5. package/dist/index.d.mts +10 -7
  6. package/dist/index.d.ts +10 -7
  7. package/dist/index.js +1705 -744
  8. package/dist/index.mjs +61 -1
  9. package/dist/tailwind-preset.js +7 -0
  10. package/dist/tailwind-preset.mjs +7 -0
  11. package/dist/ui.d.mts +3 -2
  12. package/dist/ui.d.ts +3 -2
  13. package/dist/ui.js +1584 -666
  14. package/dist/ui.mjs +31 -1
  15. package/package.json +18 -4
  16. package/src/app/globals.css +1005 -227
  17. package/src/components/icons/IconBase.tsx +20 -12
  18. package/src/components/icons/createIconBase.tsx +12 -7
  19. package/src/components/icons/icons.tsx +208 -18
  20. package/src/components/sections/AlertShowcase.tsx +2 -1
  21. package/src/components/sections/BadgeShowcase.tsx +1 -1
  22. package/src/components/sections/ButtonShowcase.tsx +6 -10
  23. package/src/components/sections/CardShowcase.tsx +22 -6
  24. package/src/components/sections/DropdownShowcase.tsx +4 -6
  25. package/src/components/sections/FilterChipsShowcase.tsx +74 -26
  26. package/src/components/sections/FormShowcase.tsx +2 -1
  27. package/src/components/sections/IconShowcase.tsx +86 -206
  28. package/src/components/sections/KbdShowcase.tsx +47 -21
  29. package/src/components/sections/ModalShowcase.tsx +7 -6
  30. package/src/components/sections/ProductLayoutsShowcase.tsx +138 -0
  31. package/src/components/sections/ProgressShowcase.tsx +50 -53
  32. package/src/components/sections/SwapShowcase.tsx +13 -5
  33. package/src/components/ui/Alert.tsx +28 -19
  34. package/src/components/ui/AuthLayout.tsx +58 -0
  35. package/src/components/ui/Badge.tsx +10 -5
  36. package/src/components/ui/Button.tsx +22 -12
  37. package/src/components/ui/ButtonChrome.tsx +1 -1
  38. package/src/components/ui/Calendar.tsx +6 -7
  39. package/src/components/ui/Card.tsx +21 -11
  40. package/src/components/ui/CollapsibleSection.tsx +11 -11
  41. package/src/components/ui/DropdownMenu.tsx +189 -65
  42. package/src/components/ui/FilterBar.tsx +165 -0
  43. package/src/components/ui/FilterChips.tsx +65 -35
  44. package/src/components/ui/FilterControls.tsx +30 -0
  45. package/src/components/ui/FormField.tsx +69 -0
  46. package/src/components/ui/Input.tsx +166 -140
  47. package/src/components/ui/Kbd.tsx +88 -13
  48. package/src/components/ui/Loader.tsx +96 -0
  49. package/src/components/ui/Modal.tsx +66 -36
  50. package/src/components/ui/SettingsLayout.tsx +94 -0
  51. package/src/components/ui/ShowcaseWrapper.tsx +4 -16
  52. package/src/components/ui/Stepper.tsx +5 -6
  53. package/src/components/ui/first-slice.test.tsx +98 -0
  54. package/src/components/ui/index.ts +62 -3
  55. package/src/components/ui/interaction-primitives.test.tsx +77 -0
  56. package/src/components/ui/kbd-icons.test.tsx +90 -0
  57. package/src/components/ui/product-patterns.test.tsx +77 -0
  58. package/src/styles/themes.css +411 -1
  59. package/tailwind-preset.ts +7 -0
  60. package/dist/index-CmS6hcUk.d.mts +0 -753
  61. package/dist/index-CmS6hcUk.d.ts +0 -753
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { IconChevronLeft, IconChevronRight } from "@/components/icons";
4
5
  import { cn } from "@/lib/cn";
5
6
 
6
7
  interface CalendarProps {
@@ -64,11 +65,10 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
64
65
  <button
65
66
  type="button"
66
67
  onClick={prevMonth}
68
+ aria-label="Previous month"
67
69
  className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
68
70
  >
69
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
70
- <path d="M15 18l-6-6 6-6" />
71
- </svg>
71
+ <IconChevronLeft className="h-5 w-5" aria-hidden />
72
72
  </button>
73
73
  <span className="text-sm font-semibold text-ph-ink">
74
74
  {monthNames[month]} {year}
@@ -76,11 +76,10 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
76
76
  <button
77
77
  type="button"
78
78
  onClick={nextMonth}
79
+ aria-label="Next month"
79
80
  className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
80
81
  >
81
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
82
- <path d="M9 18l6-6-6-6" />
83
- </svg>
82
+ <IconChevronRight className="h-5 w-5" aria-hidden />
84
83
  </button>
85
84
  </div>
86
85
 
@@ -99,7 +98,7 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
99
98
  className={cn(
100
99
  "flex h-full w-full items-center justify-center rounded-lg text-sm transition-colors",
101
100
  isSelected(day)
102
- ? "bg-ph-brand font-semibold text-white shadow-ph"
101
+ ? "bg-ph-brand font-semibold text-[var(--ph-on-accent)] shadow-ph"
103
102
  : isToday(day)
104
103
  ? "bg-ph-muted font-semibold text-ph-brand ring-1 ring-ph-brand/30"
105
104
  : "text-ph-ink hover:bg-ph-muted"
@@ -1,9 +1,9 @@
1
- import type { ReactNode } from "react";
1
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "@/lib/cn";
3
3
 
4
4
  export type CardVariant = "default" | "outlined" | "elevated" | "highlighted";
5
5
 
6
- export interface CardProps {
6
+ export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
7
7
  children?: ReactNode;
8
8
  title?: ReactNode;
9
9
  description?: ReactNode;
@@ -13,16 +13,18 @@ export interface CardProps {
13
13
  variant?: CardVariant;
14
14
  className?: string;
15
15
  bodyClassName?: string;
16
+ interactive?: boolean;
17
+ titleAs?: "h2" | "h3" | "h4";
16
18
  }
17
19
 
18
20
  const variantMap: Record<CardVariant, string> = {
19
- default: "bg-ph-surface",
20
- outlined: "border-2 border-ph-border bg-ph-surface",
21
- elevated: "bg-ph-surface shadow-ph-md",
22
- highlighted: "border border-ph-brand/35 bg-orange-50/40 shadow-ph",
21
+ default: "",
22
+ outlined: "border-2 border-ph-border",
23
+ elevated: "ph-card-elevated",
24
+ highlighted: "ph-card-highlighted",
23
25
  };
24
26
 
25
- export function Card({
27
+ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card({
26
28
  children,
27
29
  title,
28
30
  description,
@@ -32,19 +34,25 @@ export function Card({
32
34
  variant = "default",
33
35
  className,
34
36
  bodyClassName,
35
- }: CardProps) {
37
+ interactive = false,
38
+ titleAs: Title = "h3",
39
+ ...props
40
+ }, ref) {
36
41
  return (
37
42
  <div
43
+ ref={ref}
38
44
  className={cn(
39
45
  "ph-card",
40
46
  variantMap[variant],
47
+ interactive && "ph-card-interactive",
41
48
  className
42
49
  )}
50
+ {...props}
43
51
  >
44
- {media && <div className="-mx-6 -mt-6 mb-4">{media}</div>}
52
+ {media && <div className="ph-card-media">{media}</div>}
45
53
  <div className={cn("ph-card-body", bodyClassName)}>
46
54
  {title && (
47
- <h3 className="ph-card-title">{title}</h3>
55
+ <Title className="ph-card-title">{title}</Title>
48
56
  )}
49
57
  {description && (
50
58
  <p className="text-sm text-ph-subtle">{description}</p>
@@ -57,4 +65,6 @@ export function Card({
57
65
  )}
58
66
  </div>
59
67
  );
60
- }
68
+ });
69
+
70
+ Card.displayName = "Card";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useId, useState } from "react";
4
+ import { IconChevronDown } from "@/components/icons";
4
5
  import { cn } from "@/lib/cn";
5
6
 
6
7
  interface CollapsibleSectionProps {
@@ -12,31 +13,30 @@ interface CollapsibleSectionProps {
12
13
 
13
14
  export function CollapsibleSection({ title, children, defaultOpen = true, id }: CollapsibleSectionProps) {
14
15
  const [isOpen, setIsOpen] = useState(defaultOpen);
16
+ const generatedId = useId();
17
+ const contentId = `${id ?? generatedId}-content`;
15
18
 
16
19
  return (
17
20
  <section id={id} className="scroll-mt-20">
18
21
  <button
19
22
  type="button"
20
23
  onClick={() => setIsOpen(!isOpen)}
24
+ aria-expanded={isOpen}
25
+ aria-controls={contentId}
21
26
  className="mb-6 flex w-full items-center justify-between rounded-lg border border-ph-border bg-ph-muted px-5 py-4 text-left transition hover:bg-ph-muted/80"
22
27
  >
23
28
  <span className="text-lg font-bold text-ph-ink">{title}</span>
24
- <svg
29
+ <IconChevronDown
25
30
  className={cn(
26
31
  "h-5 w-5 text-ph-subtle transition-transform duration-200",
27
32
  isOpen && "rotate-180"
28
33
  )}
29
- viewBox="0 0 24 24"
30
- fill="none"
31
- stroke="currentColor"
32
- strokeWidth="2"
33
- strokeLinecap="round"
34
- strokeLinejoin="round"
35
- >
36
- <path d="M6 9l6 6 6-6" />
37
- </svg>
34
+ aria-hidden
35
+ />
38
36
  </button>
39
37
  <div
38
+ id={contentId}
39
+ aria-hidden={!isOpen}
40
40
  className={cn(
41
41
  "overflow-hidden transition-all duration-300",
42
42
  isOpen ? "max-h-[50000px] opacity-100" : "max-h-0 opacity-0"
@@ -1,39 +1,75 @@
1
- import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
2
- import { IconChevronDown } from "@/components/icons";
1
+ "use client";
2
+
3
+ import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
4
+ import type {
5
+ ButtonHTMLAttributes,
6
+ ComponentPropsWithoutRef,
7
+ HTMLAttributes,
8
+ ReactNode,
9
+ } from "react";
10
+ import { IconCheck, IconChevronDown } from "@/components/icons";
3
11
  import { cn } from "@/lib/cn";
4
12
  import { ButtonChrome } from "@/components/ui/ButtonChrome";
5
13
  import type { ButtonSize } from "@/components/ui/Button";
6
14
 
7
- export type DropdownAlign = "start" | "end";
15
+ type ContentProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.Content>;
16
+
17
+ export type DropdownAlign = NonNullable<ContentProps["align"]>;
18
+ export type DropdownSide = NonNullable<ContentProps["side"]>;
19
+
20
+ interface DropdownPlacementProps {
21
+ align?: DropdownAlign;
22
+ side?: DropdownSide;
23
+ sideOffset?: number;
24
+ alignOffset?: number;
25
+ collisionPadding?: ContentProps["collisionPadding"];
26
+ avoidCollisions?: boolean;
27
+ panelClassName?: string;
28
+ }
29
+
30
+ interface DropdownOpenProps {
31
+ open?: boolean;
32
+ defaultOpen?: boolean;
33
+ onOpenChange?: (open: boolean) => void;
34
+ modal?: boolean;
35
+ }
8
36
 
9
- export interface DropdownMenuProps extends HTMLAttributes<HTMLDetailsElement> {
10
- /** Custom trigger node (avatar, icon chip, etc.) */
37
+ export interface DropdownMenuProps
38
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
39
+ DropdownPlacementProps,
40
+ DropdownOpenProps {
41
+ /** Custom non-interactive trigger node, such as an avatar or icon chip. */
11
42
  trigger: ReactNode;
12
- /** Accessible name when trigger has no visible text */
43
+ /** Accessible name when the trigger has no visible text. */
13
44
  "aria-label": string;
14
45
  children: ReactNode;
15
- align?: DropdownAlign;
16
- panelClassName?: string;
46
+ disabled?: boolean;
17
47
  }
18
48
 
19
- export interface DropdownButtonProps extends Omit<HTMLAttributes<HTMLDetailsElement>, "children"> {
20
- /** Visible button label */
49
+ export interface DropdownButtonProps
50
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
51
+ DropdownPlacementProps,
52
+ DropdownOpenProps {
53
+ /** Visible button label. */
21
54
  label: string;
22
55
  variant?: "primary" | "secondary" | "accent";
23
56
  outline?: boolean;
24
57
  size?: ButtonSize;
25
- /** Split control (Run ▾) — divider before trailing chevron */
58
+ /** Split control styling with a divider before the trailing chevron. */
26
59
  split?: boolean;
27
60
  icon?: ReactNode;
28
- /** Defaults to chevron when omitted */
61
+ /** Defaults to a chevron when omitted. */
29
62
  sideIcon?: ReactNode;
30
63
  children: ReactNode;
31
- align?: DropdownAlign;
32
- panelClassName?: string;
33
- /** Overrides accessible name; defaults to `label` */
64
+ disabled?: boolean;
65
+ /** Overrides the accessible name; defaults to `label`. */
34
66
  "aria-label"?: string;
35
67
  }
36
68
 
69
+ export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
70
+ export type DropdownRadioGroupProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioGroup>;
71
+ export type DropdownRadioItemProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioItem>;
72
+
37
73
  const sizeClass: Record<ButtonSize, string> = {
38
74
  xs: "ph-btn-xs",
39
75
  sm: "ph-btn-sm",
@@ -47,38 +83,85 @@ const variantClass = {
47
83
  accent: "ph-btn-accent",
48
84
  } as const;
49
85
 
50
- function dropdownPanelClass(align: DropdownAlign, panelClassName?: string) {
51
- return cn(
52
- "ph-dropdown-panel absolute top-full z-50 mt-0",
53
- align === "end" ? "right-0" : "left-0",
54
- panelClassName,
86
+ function DropdownContent({
87
+ children,
88
+ align = "start",
89
+ side = "bottom",
90
+ sideOffset = 7,
91
+ alignOffset = 0,
92
+ collisionPadding = 8,
93
+ avoidCollisions = true,
94
+ panelClassName,
95
+ }: DropdownPlacementProps & { children: ReactNode }) {
96
+ return (
97
+ <DropdownPrimitive.Portal>
98
+ <DropdownPrimitive.Content
99
+ className={cn("ph-dropdown-panel", panelClassName)}
100
+ align={align}
101
+ side={side}
102
+ sideOffset={sideOffset}
103
+ alignOffset={alignOffset}
104
+ collisionPadding={collisionPadding}
105
+ avoidCollisions={avoidCollisions}
106
+ sticky="partial"
107
+ >
108
+ {children}
109
+ <DropdownPrimitive.Arrow className="ph-dropdown-arrow" width={12} height={6} />
110
+ </DropdownPrimitive.Content>
111
+ </DropdownPrimitive.Portal>
55
112
  );
56
113
  }
57
114
 
58
- /** Generic `<details>` menu — pass any trigger (avatar, icon chip, etc.). */
115
+ /** Collision-aware menu that flips sides and shifts its alignment to remain visible. */
59
116
  export function DropdownMenu({
60
117
  trigger,
61
118
  children,
62
- align = "start",
119
+ align,
120
+ side,
121
+ sideOffset,
122
+ alignOffset,
123
+ collisionPadding,
124
+ avoidCollisions,
63
125
  className,
64
126
  panelClassName,
65
127
  "aria-label": ariaLabel,
128
+ open,
129
+ defaultOpen,
130
+ onOpenChange,
131
+ modal = false,
132
+ disabled,
66
133
  ...props
67
134
  }: DropdownMenuProps) {
68
135
  return (
69
- <details className={cn("relative", className)} {...props}>
70
- <summary
71
- className="cursor-pointer list-none [&::-webkit-details-marker]:hidden"
72
- aria-label={ariaLabel}
136
+ <div className={cn("ph-dropdown", className)} {...props}>
137
+ <DropdownPrimitive.Root
138
+ open={open}
139
+ defaultOpen={defaultOpen}
140
+ onOpenChange={onOpenChange}
141
+ modal={modal}
73
142
  >
74
- {trigger}
75
- </summary>
76
- <div className={dropdownPanelClass(align, panelClassName)}>{children}</div>
77
- </details>
143
+ <DropdownPrimitive.Trigger asChild disabled={disabled}>
144
+ <button type="button" className="ph-dropdown-trigger" aria-label={ariaLabel}>
145
+ {trigger}
146
+ </button>
147
+ </DropdownPrimitive.Trigger>
148
+ <DropdownContent
149
+ align={align}
150
+ side={side}
151
+ sideOffset={sideOffset}
152
+ alignOffset={alignOffset}
153
+ collisionPadding={collisionPadding}
154
+ avoidCollisions={avoidCollisions}
155
+ panelClassName={panelClassName}
156
+ >
157
+ {children}
158
+ </DropdownContent>
159
+ </DropdownPrimitive.Root>
160
+ </div>
78
161
  );
79
162
  }
80
163
 
81
- /** Lemon-styled dropdown trigger (primary / secondary / accent) with labelled chrome. */
164
+ /** Tactile dropdown trigger backed by the same adaptive menu positioning. */
82
165
  export function DropdownButton({
83
166
  label,
84
167
  variant = "secondary",
@@ -88,51 +171,92 @@ export function DropdownButton({
88
171
  icon,
89
172
  sideIcon,
90
173
  children,
91
- align = "start",
174
+ align,
175
+ side,
176
+ sideOffset,
177
+ alignOffset,
178
+ collisionPadding,
179
+ avoidCollisions,
92
180
  className,
93
181
  panelClassName,
94
182
  "aria-label": ariaLabel,
183
+ open,
184
+ defaultOpen,
185
+ onOpenChange,
186
+ modal = false,
187
+ disabled,
95
188
  ...props
96
189
  }: DropdownButtonProps) {
97
- const usesLemonChrome = variant === "primary" || variant === "secondary";
98
- const chevron = <IconChevronDown className="h-4 w-4" aria-hidden />;
99
- const trailing = sideIcon ?? chevron;
190
+ const trailing = sideIcon ?? <IconChevronDown className="h-4 w-4" aria-hidden />;
100
191
 
101
192
  return (
102
- <details className={cn("relative", className)} {...props}>
103
- <summary
104
- className={cn(
105
- "ph-btn cursor-pointer list-none [&::-webkit-details-marker]:hidden",
106
- variantClass[variant],
107
- outline && "ph-btn-outline",
108
- sizeClass[size],
109
- split && "ph-btn-split",
110
- )}
111
- aria-label={ariaLabel ?? label}
193
+ <div className={cn("ph-dropdown", className)} {...props}>
194
+ <DropdownPrimitive.Root
195
+ open={open}
196
+ defaultOpen={defaultOpen}
197
+ onOpenChange={onOpenChange}
198
+ modal={modal}
112
199
  >
113
- {usesLemonChrome ? (
114
- <ButtonChrome label={label} icon={icon} sideIcon={trailing} split={split} />
115
- ) : (
116
- <>
117
- {icon != null ? <span className="ph-btn-icon">{icon}</span> : null}
118
- <span className="ph-btn-label">{label}</span>
119
- <span className="ph-btn-side-icon">{trailing}</span>
120
- </>
121
- )}
122
- </summary>
123
- <div className={dropdownPanelClass(align, panelClassName)}>{children}</div>
124
- </details>
200
+ <DropdownPrimitive.Trigger asChild disabled={disabled}>
201
+ <button
202
+ type="button"
203
+ disabled={disabled}
204
+ className={cn(
205
+ "ph-btn ph-btn-raised ph-dropdown-button",
206
+ variantClass[variant],
207
+ outline && "ph-btn-outline",
208
+ sizeClass[size],
209
+ split && "ph-btn-split",
210
+ )}
211
+ aria-label={ariaLabel}
212
+ >
213
+ <ButtonChrome label={label} icon={icon} sideIcon={trailing} split={split} />
214
+ </button>
215
+ </DropdownPrimitive.Trigger>
216
+ <DropdownContent
217
+ align={align}
218
+ side={side}
219
+ sideOffset={sideOffset}
220
+ alignOffset={alignOffset}
221
+ collisionPadding={collisionPadding}
222
+ avoidCollisions={avoidCollisions}
223
+ panelClassName={panelClassName}
224
+ >
225
+ {children}
226
+ </DropdownContent>
227
+ </DropdownPrimitive.Root>
228
+ </div>
125
229
  );
126
230
  }
127
231
 
128
- export function DropdownItem({
129
- className,
130
- children,
131
- ...props
132
- }: ButtonHTMLAttributes<HTMLButtonElement>) {
232
+ export function DropdownItem({ className, children, disabled, ...props }: DropdownItemProps) {
233
+ return (
234
+ <DropdownPrimitive.Item asChild disabled={disabled}>
235
+ <button
236
+ type="button"
237
+ disabled={disabled}
238
+ className={cn("ph-dropdown-item w-full text-left", className)}
239
+ {...props}
240
+ >
241
+ {children}
242
+ </button>
243
+ </DropdownPrimitive.Item>
244
+ );
245
+ }
246
+
247
+ export function DropdownRadioGroup(props: DropdownRadioGroupProps) {
248
+ return <DropdownPrimitive.RadioGroup {...props} />;
249
+ }
250
+
251
+ export function DropdownRadioItem({ className, children, ...props }: DropdownRadioItemProps) {
133
252
  return (
134
- <button type="button" className={cn("ph-dropdown-item w-full text-left", className)} {...props}>
135
- {children}
136
- </button>
253
+ <DropdownPrimitive.RadioItem className={cn("ph-dropdown-item ph-dropdown-radio-item", className)} {...props}>
254
+ <span className="ph-dropdown-item-indicator" aria-hidden>
255
+ <DropdownPrimitive.ItemIndicator>
256
+ <IconCheck className="h-3.5 w-3.5" />
257
+ </DropdownPrimitive.ItemIndicator>
258
+ </span>
259
+ <span>{children}</span>
260
+ </DropdownPrimitive.RadioItem>
137
261
  );
138
262
  }
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import { IconArrowDown, IconTuning } from "@/components/icons";
5
+ import { cn } from "@/lib/cn";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownRadioGroup,
9
+ DropdownRadioItem,
10
+ type DropdownAlign,
11
+ } from "@/components/ui/DropdownMenu";
12
+
13
+ export interface FilterMenuOption {
14
+ value: string;
15
+ label: ReactNode;
16
+ /** Plain-text label used for typeahead and accessible trigger naming. */
17
+ textValue?: string;
18
+ disabled?: boolean;
19
+ }
20
+
21
+ export interface FilterMenuProps {
22
+ label: string;
23
+ value?: string;
24
+ options: readonly FilterMenuOption[];
25
+ onValueChange: (value: string) => void;
26
+ placeholder?: ReactNode;
27
+ align?: DropdownAlign;
28
+ disabled?: boolean;
29
+ className?: string;
30
+ }
31
+
32
+ export interface SortMenuProps extends Omit<FilterMenuProps, "placeholder"> {
33
+ direction?: "ascending" | "descending";
34
+ }
35
+
36
+ export interface FilterBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
37
+ children: ReactNode;
38
+ label?: string;
39
+ onClear?: () => void;
40
+ clearLabel?: string;
41
+ resultCount?: ReactNode;
42
+ }
43
+
44
+ export function FilterBar({
45
+ children,
46
+ label = "Filters and sorting",
47
+ onClear,
48
+ clearLabel = "Clear all",
49
+ resultCount,
50
+ className,
51
+ ...props
52
+ }: FilterBarProps) {
53
+ return (
54
+ <div className={cn("ph-filter-bar", className)} {...props}>
55
+ <div className="ph-filter-bar__controls" role="group" aria-label={label}>
56
+ {children}
57
+ {onClear ? (
58
+ <button type="button" className="ph-filter-bar__clear" onClick={onClear}>
59
+ {clearLabel}
60
+ </button>
61
+ ) : null}
62
+ </div>
63
+ {resultCount != null ? (
64
+ <output className="ph-filter-bar__results" aria-live="polite">
65
+ {resultCount}
66
+ </output>
67
+ ) : null}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export function FilterMenu({
73
+ label,
74
+ value,
75
+ options,
76
+ onValueChange,
77
+ placeholder = "Any",
78
+ align = "start",
79
+ disabled,
80
+ className,
81
+ }: FilterMenuProps) {
82
+ const selected = options.find((option) => option.value === value);
83
+ const displayValue = selected?.label ?? placeholder;
84
+ const accessibleValue = selected ? getOptionText(selected) : String(placeholder);
85
+
86
+ return (
87
+ <DropdownMenu
88
+ aria-label={`${label}: ${accessibleValue}`}
89
+ align={align}
90
+ disabled={disabled}
91
+ className={className}
92
+ trigger={
93
+ <span className={cn("ph-filter-menu-trigger", selected && "ph-filter-menu-trigger--active")}>
94
+ <IconTuning className="h-3.5 w-3.5" aria-hidden />
95
+ <span className="ph-filter-menu-trigger__label">{label}</span>
96
+ <span className="ph-filter-menu-trigger__value">{displayValue}</span>
97
+ </span>
98
+ }
99
+ >
100
+ <DropdownRadioGroup value={value} onValueChange={onValueChange}>
101
+ {options.map((option) => (
102
+ <DropdownRadioItem
103
+ key={option.value}
104
+ value={option.value}
105
+ textValue={getOptionText(option)}
106
+ disabled={option.disabled}
107
+ >
108
+ {option.label}
109
+ </DropdownRadioItem>
110
+ ))}
111
+ </DropdownRadioGroup>
112
+ </DropdownMenu>
113
+ );
114
+ }
115
+
116
+ export function SortMenu({
117
+ label,
118
+ value,
119
+ options,
120
+ onValueChange,
121
+ direction = "descending",
122
+ align = "end",
123
+ disabled,
124
+ className,
125
+ }: SortMenuProps) {
126
+ const selected = options.find((option) => option.value === value);
127
+
128
+ return (
129
+ <DropdownMenu
130
+ aria-label={`${label}: ${selected ? getOptionText(selected) : "Choose sort order"}`}
131
+ align={align}
132
+ disabled={disabled}
133
+ className={className}
134
+ trigger={
135
+ <span className="ph-filter-menu-trigger ph-sort-menu-trigger">
136
+ <IconArrowDown
137
+ className={cn("h-3.5 w-3.5", direction === "ascending" && "rotate-180")}
138
+ aria-hidden
139
+ />
140
+ <span className="ph-filter-menu-trigger__label">{label}</span>
141
+ {selected ? <span className="ph-filter-menu-trigger__value">{selected.label}</span> : null}
142
+ </span>
143
+ }
144
+ >
145
+ <DropdownRadioGroup value={value} onValueChange={onValueChange}>
146
+ {options.map((option) => (
147
+ <DropdownRadioItem
148
+ key={option.value}
149
+ value={option.value}
150
+ textValue={getOptionText(option)}
151
+ disabled={option.disabled}
152
+ >
153
+ {option.label}
154
+ </DropdownRadioItem>
155
+ ))}
156
+ </DropdownRadioGroup>
157
+ </DropdownMenu>
158
+ );
159
+ }
160
+
161
+ function getOptionText(option: FilterMenuOption) {
162
+ if (option.textValue) return option.textValue;
163
+ if (typeof option.label === "string" || typeof option.label === "number") return String(option.label);
164
+ return option.value;
165
+ }