@xenide-io/the-old-ui-theme 0.2.6 → 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 (126) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-FDMD3IIN.mjs +3831 -0
  3. package/dist/index-D1RTT2Ap.d.mts +1044 -0
  4. package/dist/index-D1RTT2Ap.d.ts +1044 -0
  5. package/dist/index.d.mts +11 -758
  6. package/dist/index.d.ts +11 -758
  7. package/dist/index.js +1749 -768
  8. package/dist/index.mjs +312 -2846
  9. package/dist/tailwind-preset.js +7 -0
  10. package/dist/tailwind-preset.mjs +7 -0
  11. package/dist/ui.d.mts +4 -0
  12. package/dist/ui.d.ts +4 -0
  13. package/dist/ui.js +3864 -0
  14. package/dist/ui.mjs +169 -0
  15. package/package.json +28 -6
  16. package/src/app/globals.css +1005 -227
  17. package/src/components/charts/index.ts +8 -0
  18. package/src/components/charts/ph-insight-charts.tsx +162 -0
  19. package/src/components/dashboard/DashboardFiltersBar.tsx +19 -0
  20. package/src/components/dashboard/DashboardGrid.tsx +23 -0
  21. package/src/components/dashboard/DashboardInsightPlaceholder.tsx +37 -0
  22. package/src/components/dashboard/DashboardKpiCard.tsx +25 -0
  23. package/src/components/dashboard/DashboardToolbar.tsx +23 -0
  24. package/src/components/dashboard/DashboardWell.tsx +10 -0
  25. package/src/components/dashboard/InsightMiniCard.tsx +26 -0
  26. package/src/components/dashboard/InsightShell.tsx +37 -0
  27. package/src/components/dashboard/InsightShellHeader.tsx +22 -0
  28. package/src/components/dashboard/MiniTrendBars.tsx +51 -0
  29. package/src/components/dashboard/index.ts +31 -0
  30. package/src/components/dashboard/types.ts +9 -0
  31. package/src/components/icons/IconBase.tsx +47 -0
  32. package/src/components/icons/createIconBase.tsx +32 -0
  33. package/src/components/icons/icons.tsx +557 -0
  34. package/src/components/icons/index.ts +3 -0
  35. package/src/components/sections/AccordionShowcase.tsx +45 -0
  36. package/src/components/sections/AlertShowcase.tsx +40 -0
  37. package/src/components/sections/AvatarShowcase.tsx +80 -0
  38. package/src/components/sections/BadgeShowcase.tsx +65 -0
  39. package/src/components/sections/ButtonShowcase.tsx +308 -0
  40. package/src/components/sections/CalendarShowcase.tsx +35 -0
  41. package/src/components/sections/CardShowcase.tsx +159 -0
  42. package/src/components/sections/ChartShowcase.tsx +190 -0
  43. package/src/components/sections/CodeMockupShowcase.tsx +56 -0
  44. package/src/components/sections/ColorPalette.tsx +117 -0
  45. package/src/components/sections/CommandPaletteShowcase.tsx +48 -0
  46. package/src/components/sections/CountdownShowcase.tsx +43 -0
  47. package/src/components/sections/DashboardShowcase.tsx +191 -0
  48. package/src/components/sections/DiffShowcase.tsx +70 -0
  49. package/src/components/sections/DrawerShowcase.tsx +97 -0
  50. package/src/components/sections/DropdownShowcase.tsx +98 -0
  51. package/src/components/sections/EmptyStateShowcase.tsx +50 -0
  52. package/src/components/sections/FilterChipsShowcase.tsx +98 -0
  53. package/src/components/sections/FormShowcase.tsx +127 -0
  54. package/src/components/sections/HoverCardShowcase.tsx +50 -0
  55. package/src/components/sections/IconShowcase.tsx +127 -0
  56. package/src/components/sections/IndicatorShowcase.tsx +52 -0
  57. package/src/components/sections/KbdShowcase.tsx +57 -0
  58. package/src/components/sections/ModalShowcase.tsx +211 -0
  59. package/src/components/sections/NavigationShowcase.tsx +199 -0
  60. package/src/components/sections/ProductLayoutsShowcase.tsx +138 -0
  61. package/src/components/sections/ProgressShowcase.tsx +82 -0
  62. package/src/components/sections/SegmentedControlShowcase.tsx +49 -0
  63. package/src/components/sections/SetupThemeShowcase.tsx +262 -0
  64. package/src/components/sections/StackShowcase.tsx +33 -0
  65. package/src/components/sections/StatShowcase.tsx +129 -0
  66. package/src/components/sections/StepperShowcase.tsx +37 -0
  67. package/src/components/sections/SwapShowcase.tsx +99 -0
  68. package/src/components/sections/TabShowcase.tsx +84 -0
  69. package/src/components/sections/TableShowcase.tsx +140 -0
  70. package/src/components/sections/TimelineShowcase.tsx +83 -0
  71. package/src/components/sections/ToastShowcase.tsx +108 -0
  72. package/src/components/sections/TooltipShowcase.tsx +44 -0
  73. package/src/components/sections/UtilityShowcase.tsx +81 -0
  74. package/src/components/ui/Accordion.tsx +77 -0
  75. package/src/components/ui/Alert.tsx +77 -0
  76. package/src/components/ui/AuthLayout.tsx +58 -0
  77. package/src/components/ui/Avatar.tsx +97 -0
  78. package/src/components/ui/Badge.tsx +60 -0
  79. package/src/components/ui/Button.tsx +120 -0
  80. package/src/components/ui/ButtonChrome.tsx +20 -0
  81. package/src/components/ui/Calendar.tsx +115 -0
  82. package/src/components/ui/Card.tsx +70 -0
  83. package/src/components/ui/ChatBubble.tsx +71 -0
  84. package/src/components/ui/CodeBlock.tsx +48 -0
  85. package/src/components/ui/CollapsibleSection.tsx +49 -0
  86. package/src/components/ui/CommandPalette.tsx +137 -0
  87. package/src/components/ui/ComponentDocs.tsx +52 -0
  88. package/src/components/ui/Drawer.tsx +71 -0
  89. package/src/components/ui/DropdownMenu.tsx +262 -0
  90. package/src/components/ui/EmptyState.tsx +33 -0
  91. package/src/components/ui/FilterBar.tsx +165 -0
  92. package/src/components/ui/FilterChips.tsx +80 -0
  93. package/src/components/ui/FilterControls.tsx +30 -0
  94. package/src/components/ui/FormField.tsx +69 -0
  95. package/src/components/ui/HoverCard.tsx +36 -0
  96. package/src/components/ui/Indicator.tsx +61 -0
  97. package/src/components/ui/Input.tsx +385 -0
  98. package/src/components/ui/Kbd.tsx +113 -0
  99. package/src/components/ui/Loader.tsx +96 -0
  100. package/src/components/ui/Modal.tsx +105 -0
  101. package/src/components/ui/Navigation.tsx +94 -0
  102. package/src/components/ui/Panel.tsx +18 -0
  103. package/src/components/ui/Progress.tsx +59 -0
  104. package/src/components/ui/Rating.tsx +65 -0
  105. package/src/components/ui/SearchInput.tsx +106 -0
  106. package/src/components/ui/SegmentedControl.tsx +44 -0
  107. package/src/components/ui/SettingsLayout.tsx +94 -0
  108. package/src/components/ui/ShowcaseWrapper.tsx +124 -0
  109. package/src/components/ui/Stat.tsx +39 -0
  110. package/src/components/ui/Stepper.tsx +74 -0
  111. package/src/components/ui/Table.tsx +55 -0
  112. package/src/components/ui/Tabs.tsx +53 -0
  113. package/src/components/ui/Terminal.tsx +54 -0
  114. package/src/components/ui/ThemeDomSync.tsx +17 -0
  115. package/src/components/ui/ThemeManager.tsx +18 -0
  116. package/src/components/ui/ThemeSwitcher.tsx +46 -0
  117. package/src/components/ui/Timeline.tsx +60 -0
  118. package/src/components/ui/Toast.tsx +70 -0
  119. package/src/components/ui/Typography.tsx +129 -0
  120. package/src/components/ui/first-slice.test.tsx +98 -0
  121. package/src/components/ui/index.ts +151 -0
  122. package/src/components/ui/interaction-primitives.test.tsx +77 -0
  123. package/src/components/ui/kbd-icons.test.tsx +90 -0
  124. package/src/components/ui/product-patterns.test.tsx +77 -0
  125. package/src/styles/themes.css +528 -0
  126. package/tailwind-preset.ts +7 -0
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from "react";
2
+ import { IconClose } from "@/components/icons";
3
+ import { cn } from "@/lib/cn";
4
+ import { Button } from "@/components/ui/Button";
5
+
6
+ export type DrawerSide = "left" | "right";
7
+ export type DrawerSize = "sm" | "md" | "lg";
8
+
9
+ export interface DrawerProps {
10
+ open?: boolean;
11
+ onClose?: () => void;
12
+ title?: ReactNode;
13
+ children?: ReactNode;
14
+ footer?: ReactNode;
15
+ side?: DrawerSide;
16
+ size?: DrawerSize;
17
+ className?: string;
18
+ showCloseButton?: boolean;
19
+ }
20
+
21
+ const sideMap: Record<DrawerSide, string> = {
22
+ left: "left-0 border-r",
23
+ right: "right-0 border-l",
24
+ };
25
+
26
+ const sizeMap: Record<DrawerSize, string> = {
27
+ sm: "w-[min(100%,18rem)]",
28
+ md: "w-[min(100%,22rem)]",
29
+ lg: "w-[min(100%,30rem)]",
30
+ };
31
+
32
+ export function Drawer({
33
+ open = false,
34
+ onClose,
35
+ title,
36
+ children,
37
+ footer,
38
+ side = "right",
39
+ size = "md",
40
+ className,
41
+ showCloseButton = true,
42
+ }: DrawerProps) {
43
+ if (!open) return null;
44
+
45
+ return (
46
+ <>
47
+ <div className="fixed inset-0 z-[120] bg-black/45 backdrop-blur-[2px]" role="presentation" onClick={onClose} />
48
+ <aside
49
+ className={cn(
50
+ "fixed inset-y-0 z-[130] flex flex-col overflow-y-auto border-ph-border bg-ph-surface shadow-ph-md",
51
+ sideMap[side],
52
+ sizeMap[size],
53
+ className
54
+ )}
55
+ >
56
+ {(title || showCloseButton) && (
57
+ <header className="flex items-center justify-between border-b border-ph-border px-4 py-3">
58
+ <span className="text-sm font-bold text-ph-ink">{title}</span>
59
+ {showCloseButton && onClose && (
60
+ <Button variant="ghost" className="ph-btn-circle" onClick={onClose} aria-label="Close drawer">
61
+ <IconClose className="h-5 w-5" />
62
+ </Button>
63
+ )}
64
+ </header>
65
+ )}
66
+ <div className="flex-1">{children}</div>
67
+ {footer && <footer className="border-t border-ph-border px-4 py-3">{footer}</footer>}
68
+ </aside>
69
+ </>
70
+ );
71
+ }
@@ -0,0 +1,262 @@
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";
11
+ import { cn } from "@/lib/cn";
12
+ import { ButtonChrome } from "@/components/ui/ButtonChrome";
13
+ import type { ButtonSize } from "@/components/ui/Button";
14
+
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
+ }
36
+
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. */
42
+ trigger: ReactNode;
43
+ /** Accessible name when the trigger has no visible text. */
44
+ "aria-label": string;
45
+ children: ReactNode;
46
+ disabled?: boolean;
47
+ }
48
+
49
+ export interface DropdownButtonProps
50
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
51
+ DropdownPlacementProps,
52
+ DropdownOpenProps {
53
+ /** Visible button label. */
54
+ label: string;
55
+ variant?: "primary" | "secondary" | "accent";
56
+ outline?: boolean;
57
+ size?: ButtonSize;
58
+ /** Split control styling with a divider before the trailing chevron. */
59
+ split?: boolean;
60
+ icon?: ReactNode;
61
+ /** Defaults to a chevron when omitted. */
62
+ sideIcon?: ReactNode;
63
+ children: ReactNode;
64
+ disabled?: boolean;
65
+ /** Overrides the accessible name; defaults to `label`. */
66
+ "aria-label"?: string;
67
+ }
68
+
69
+ export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
70
+ export type DropdownRadioGroupProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioGroup>;
71
+ export type DropdownRadioItemProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioItem>;
72
+
73
+ const sizeClass: Record<ButtonSize, string> = {
74
+ xs: "ph-btn-xs",
75
+ sm: "ph-btn-sm",
76
+ md: "",
77
+ lg: "ph-btn-lg",
78
+ };
79
+
80
+ const variantClass = {
81
+ primary: "ph-btn-primary",
82
+ secondary: "ph-btn-secondary",
83
+ accent: "ph-btn-accent",
84
+ } as const;
85
+
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>
112
+ );
113
+ }
114
+
115
+ /** Collision-aware menu that flips sides and shifts its alignment to remain visible. */
116
+ export function DropdownMenu({
117
+ trigger,
118
+ children,
119
+ align,
120
+ side,
121
+ sideOffset,
122
+ alignOffset,
123
+ collisionPadding,
124
+ avoidCollisions,
125
+ className,
126
+ panelClassName,
127
+ "aria-label": ariaLabel,
128
+ open,
129
+ defaultOpen,
130
+ onOpenChange,
131
+ modal = false,
132
+ disabled,
133
+ ...props
134
+ }: DropdownMenuProps) {
135
+ return (
136
+ <div className={cn("ph-dropdown", className)} {...props}>
137
+ <DropdownPrimitive.Root
138
+ open={open}
139
+ defaultOpen={defaultOpen}
140
+ onOpenChange={onOpenChange}
141
+ modal={modal}
142
+ >
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>
161
+ );
162
+ }
163
+
164
+ /** Tactile dropdown trigger backed by the same adaptive menu positioning. */
165
+ export function DropdownButton({
166
+ label,
167
+ variant = "secondary",
168
+ outline = false,
169
+ size = "md",
170
+ split = false,
171
+ icon,
172
+ sideIcon,
173
+ children,
174
+ align,
175
+ side,
176
+ sideOffset,
177
+ alignOffset,
178
+ collisionPadding,
179
+ avoidCollisions,
180
+ className,
181
+ panelClassName,
182
+ "aria-label": ariaLabel,
183
+ open,
184
+ defaultOpen,
185
+ onOpenChange,
186
+ modal = false,
187
+ disabled,
188
+ ...props
189
+ }: DropdownButtonProps) {
190
+ const trailing = sideIcon ?? <IconChevronDown className="h-4 w-4" aria-hidden />;
191
+
192
+ return (
193
+ <div className={cn("ph-dropdown", className)} {...props}>
194
+ <DropdownPrimitive.Root
195
+ open={open}
196
+ defaultOpen={defaultOpen}
197
+ onOpenChange={onOpenChange}
198
+ modal={modal}
199
+ >
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>
229
+ );
230
+ }
231
+
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) {
252
+ return (
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>
261
+ );
262
+ }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/cn";
4
+
5
+ interface EmptyStateProps {
6
+ icon?: React.ReactNode;
7
+ title: string;
8
+ description?: string;
9
+ action?: React.ReactNode;
10
+ className?: string;
11
+ }
12
+
13
+ export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
14
+ return (
15
+ <div
16
+ className={cn(
17
+ "flex flex-col items-center justify-center rounded-xl border border-dashed border-ph-border bg-ph-muted/50 px-8 py-16 text-center",
18
+ className
19
+ )}
20
+ >
21
+ {icon && (
22
+ <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-ph-muted text-ph-mutedtext">
23
+ {icon}
24
+ </div>
25
+ )}
26
+ <h3 className="text-base font-semibold text-ph-ink">{title}</h3>
27
+ {description && (
28
+ <p className="mt-1 max-w-sm text-sm text-ph-subtle">{description}</p>
29
+ )}
30
+ {action && <div className="mt-4">{action}</div>}
31
+ </div>
32
+ );
33
+ }
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { IconClose } from "@/components/icons";
5
+ import { cn } from "@/lib/cn";
6
+
7
+ export interface FilterChip {
8
+ id: string;
9
+ label: ReactNode;
10
+ value?: ReactNode;
11
+ active?: boolean;
12
+ removeLabel?: string;
13
+ }
14
+
15
+ export interface FilterChipsProps {
16
+ chips: readonly FilterChip[];
17
+ onToggle?: (id: string) => void;
18
+ onRemove?: (id: string) => void;
19
+ className?: string;
20
+ "aria-label"?: string;
21
+ }
22
+
23
+ export function FilterChips({
24
+ chips,
25
+ onToggle,
26
+ onRemove,
27
+ className,
28
+ "aria-label": ariaLabel = "Applied filters",
29
+ }: FilterChipsProps) {
30
+ return (
31
+ <div className={cn("ph-filter-chips", className)} role="group" aria-label={ariaLabel}>
32
+ {chips.map((chip) => {
33
+ const label = chip.removeLabel ?? getChipText(chip);
34
+ const content = (
35
+ <>
36
+ <span className="ph-filter-chip__label">{chip.label}</span>
37
+ {chip.value != null ? <span className="ph-filter-chip__value">{chip.value}</span> : null}
38
+ </>
39
+ );
40
+
41
+ return (
42
+ <span
43
+ key={chip.id}
44
+ className={cn("ph-filter-chip", chip.active && "ph-filter-chip--active")}
45
+ data-state={chip.active ? "on" : "off"}
46
+ >
47
+ {onToggle ? (
48
+ <button
49
+ type="button"
50
+ className="ph-filter-chip__main"
51
+ aria-pressed={Boolean(chip.active)}
52
+ onClick={() => onToggle(chip.id)}
53
+ >
54
+ {content}
55
+ </button>
56
+ ) : (
57
+ <span className="ph-filter-chip__main">{content}</span>
58
+ )}
59
+ {chip.active && onRemove ? (
60
+ <button
61
+ type="button"
62
+ className="ph-filter-chip__remove"
63
+ aria-label={`Remove ${label} filter`}
64
+ onClick={() => onRemove(chip.id)}
65
+ >
66
+ <IconClose className="h-3 w-3" aria-hidden />
67
+ </button>
68
+ ) : null}
69
+ </span>
70
+ );
71
+ })}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function getChipText(chip: FilterChip) {
77
+ const label = typeof chip.label === "string" || typeof chip.label === "number" ? String(chip.label) : chip.id;
78
+ const value = typeof chip.value === "string" || typeof chip.value === "number" ? String(chip.value) : null;
79
+ return value ? `${label}: ${value}` : label;
80
+ }
@@ -0,0 +1,30 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "@/lib/cn";
3
+ import { FilterBar, type FilterBarProps } from "@/components/ui/FilterBar";
4
+ import { FilterChips, type FilterChipsProps } from "@/components/ui/FilterChips";
5
+
6
+ export interface FilterControlsProps extends ComponentPropsWithoutRef<"div"> {
7
+ children: ReactNode;
8
+ barProps?: Omit<FilterBarProps, "children">;
9
+ chipsProps?: FilterChipsProps;
10
+ emptyState?: ReactNode;
11
+ }
12
+
13
+ /** Combines filter controls and their applied chips without owning filter state. */
14
+ export function FilterControls({
15
+ children,
16
+ barProps,
17
+ chipsProps,
18
+ emptyState,
19
+ className,
20
+ ...props
21
+ }: FilterControlsProps) {
22
+ const hasChips = Boolean(chipsProps?.chips.length);
23
+
24
+ return (
25
+ <div className={cn("ph-filter-controls", className)} {...props}>
26
+ <FilterBar {...barProps}>{children}</FilterBar>
27
+ {hasChips && chipsProps ? <FilterChips {...chipsProps} /> : emptyState ?? null}
28
+ </div>
29
+ );
30
+ }