@xenide-io/the-old-ui-theme 0.2.9 → 0.3.1

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 (133) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-5HSOBJ4F.mjs +5968 -0
  3. package/dist/index-BgG8Homu.d.mts +1461 -0
  4. package/dist/index-BgG8Homu.d.ts +1461 -0
  5. package/dist/index.d.mts +206 -100
  6. package/dist/index.d.ts +206 -100
  7. package/dist/index.js +4275 -1455
  8. package/dist/index.mjs +689 -735
  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 +3449 -701
  14. package/dist/ui.mjs +106 -3
  15. package/package.json +20 -16
  16. package/src/app/globals.css +1953 -266
  17. package/src/components/charts/index.ts +9 -8
  18. package/src/components/charts/quill/BarChart.tsx +85 -0
  19. package/src/components/charts/quill/FunnelChart.tsx +49 -0
  20. package/src/components/charts/quill/InsightShell.tsx +27 -0
  21. package/src/components/charts/quill/MetricCard.tsx +44 -0
  22. package/src/components/charts/quill/PieChart.tsx +81 -0
  23. package/src/components/charts/quill/Sparkline.tsx +57 -0
  24. package/src/components/charts/quill/TimeSeriesLineChart.tsx +62 -0
  25. package/src/components/charts/quill/index.ts +9 -0
  26. package/src/components/icons/IconBase.tsx +20 -12
  27. package/src/components/icons/createIconBase.tsx +12 -7
  28. package/src/components/icons/icons.tsx +210 -18
  29. package/src/components/icons/index.ts +3 -0
  30. package/src/components/icons/lemon-brand-icons.tsx +16 -0
  31. package/src/components/icons/lemon-category-icons.tsx +72 -0
  32. package/src/components/icons/lemon-icons.tsx +57 -0
  33. package/src/components/sections/AlertShowcase.tsx +2 -1
  34. package/src/components/sections/BadgeShowcase.tsx +1 -1
  35. package/src/components/sections/ButtonShowcase.tsx +6 -10
  36. package/src/components/sections/CardShowcase.tsx +22 -6
  37. package/src/components/sections/DropdownShowcase.tsx +4 -6
  38. package/src/components/sections/FilterChipsShowcase.tsx +74 -26
  39. package/src/components/sections/FormShowcase.tsx +2 -1
  40. package/src/components/sections/IconShowcase.tsx +195 -212
  41. package/src/components/sections/KbdShowcase.tsx +47 -21
  42. package/src/components/sections/ModalShowcase.tsx +7 -6
  43. package/src/components/sections/NewComponentsShowcase.tsx +1052 -0
  44. package/src/components/sections/ProductLayoutsShowcase.tsx +78 -0
  45. package/src/components/sections/ProgressShowcase.tsx +50 -53
  46. package/src/components/sections/QuillChartShowcase.tsx +92 -0
  47. package/src/components/sections/QuillDashboardShowcase.tsx +149 -0
  48. package/src/components/sections/SidebarShowcase.tsx +151 -0
  49. package/src/components/sections/SwapShowcase.tsx +13 -5
  50. package/src/components/sections/TableShowcase.tsx +2 -0
  51. package/src/components/sections/TooltipShowcase.tsx +19 -25
  52. package/src/components/ui/Alert.tsx +28 -19
  53. package/src/components/ui/AlertDialog.tsx +62 -0
  54. package/src/components/ui/AppLayout.tsx +27 -0
  55. package/src/components/ui/AuthLayout.tsx +58 -0
  56. package/src/components/ui/Autocomplete.tsx +106 -0
  57. package/src/components/ui/Badge.tsx +10 -5
  58. package/src/components/ui/Banner.tsx +63 -0
  59. package/src/components/ui/Button.tsx +22 -12
  60. package/src/components/ui/ButtonChrome.tsx +1 -1
  61. package/src/components/ui/ButtonGroup.tsx +16 -0
  62. package/src/components/ui/Calendar.tsx +6 -7
  63. package/src/components/ui/Card.tsx +21 -11
  64. package/src/components/ui/Chip.tsx +46 -0
  65. package/src/components/ui/Collapsible.tsx +46 -0
  66. package/src/components/ui/CollapsibleSection.tsx +11 -11
  67. package/src/components/ui/Combobox.tsx +119 -0
  68. package/src/components/ui/ContextMenu.tsx +81 -0
  69. package/src/components/ui/DataTable.tsx +133 -0
  70. package/src/components/ui/DatePicker.tsx +96 -0
  71. package/src/components/ui/Dialog.tsx +75 -0
  72. package/src/components/ui/Divider.tsx +39 -0
  73. package/src/components/ui/Dot.tsx +32 -0
  74. package/src/components/ui/DropdownMenu.tsx +189 -65
  75. package/src/components/ui/FilterBar.tsx +165 -0
  76. package/src/components/ui/FilterChips.tsx +65 -35
  77. package/src/components/ui/FilterControls.tsx +30 -0
  78. package/src/components/ui/FormField.tsx +69 -0
  79. package/src/components/ui/Input.tsx +166 -140
  80. package/src/components/ui/InputGroup.tsx +16 -0
  81. package/src/components/ui/Kbd.tsx +88 -13
  82. package/src/components/ui/Lettermark.tsx +47 -0
  83. package/src/components/ui/Link.tsx +32 -0
  84. package/src/components/ui/Loader.tsx +96 -0
  85. package/src/components/ui/LoadingBar.tsx +25 -0
  86. package/src/components/ui/Menubar.tsx +66 -0
  87. package/src/components/ui/Metric.tsx +34 -0
  88. package/src/components/ui/Modal.tsx +66 -36
  89. package/src/components/ui/NumberField.tsx +76 -0
  90. package/src/components/ui/Popover.tsx +73 -0
  91. package/src/components/ui/ProgressCircle.tsx +63 -0
  92. package/src/components/ui/Resizable.tsx +71 -0
  93. package/src/components/ui/Row.tsx +51 -0
  94. package/src/components/ui/ScrollArea.tsx +54 -0
  95. package/src/components/ui/SettingsLayout.tsx +94 -0
  96. package/src/components/ui/ShowcaseWrapper.tsx +4 -16
  97. package/src/components/ui/Sidebar.tsx +77 -0
  98. package/src/components/ui/Snack.tsx +48 -0
  99. package/src/components/ui/Spinner.tsx +30 -0
  100. package/src/components/ui/Splotch.tsx +33 -0
  101. package/src/components/ui/Stepper.tsx +5 -6
  102. package/src/components/ui/Table.tsx +2 -0
  103. package/src/components/ui/Tag.tsx +65 -0
  104. package/src/components/ui/Toggle.tsx +62 -0
  105. package/src/components/ui/Tooltip.test.tsx +58 -0
  106. package/src/components/ui/Tooltip.tsx +211 -0
  107. package/src/components/ui/Widget.tsx +31 -0
  108. package/src/components/ui/first-slice.test.tsx +98 -0
  109. package/src/components/ui/index.ts +335 -22
  110. package/src/components/ui/interaction-primitives.test.tsx +77 -0
  111. package/src/components/ui/kbd-icons.test.tsx +90 -0
  112. package/src/components/ui/product-patterns.test.tsx +77 -0
  113. package/src/styles/themes.css +411 -1
  114. package/tailwind-preset.ts +7 -0
  115. package/dist/chunk-SHF57J7U.mjs +0 -2910
  116. package/dist/index-CmS6hcUk.d.mts +0 -753
  117. package/dist/index-CmS6hcUk.d.ts +0 -753
  118. package/src/components/charts/ph-insight-charts.tsx +0 -162
  119. package/src/components/dashboard/DashboardFiltersBar.tsx +0 -19
  120. package/src/components/dashboard/DashboardGrid.tsx +0 -23
  121. package/src/components/dashboard/DashboardInsightPlaceholder.tsx +0 -37
  122. package/src/components/dashboard/DashboardKpiCard.tsx +0 -25
  123. package/src/components/dashboard/DashboardToolbar.tsx +0 -23
  124. package/src/components/dashboard/DashboardWell.tsx +0 -10
  125. package/src/components/dashboard/InsightMiniCard.tsx +0 -26
  126. package/src/components/dashboard/InsightShell.tsx +0 -37
  127. package/src/components/dashboard/InsightShellHeader.tsx +0 -22
  128. package/src/components/dashboard/MiniTrendBars.tsx +0 -51
  129. package/src/components/dashboard/index.ts +0 -31
  130. package/src/components/dashboard/types.ts +0 -9
  131. package/src/components/sections/ChartShowcase.tsx +0 -190
  132. package/src/components/sections/DashboardShowcase.tsx +0 -191
  133. package/src/components/sections/StatShowcase.tsx +0 -129
@@ -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
+ }
@@ -1,50 +1,80 @@
1
1
  "use client";
2
2
 
3
- import { cn } from "@/lib/cn";
3
+ import type { ReactNode } from "react";
4
4
  import { IconClose } from "@/components/icons";
5
+ import { cn } from "@/lib/cn";
5
6
 
6
- interface FilterChip {
7
+ export interface FilterChip {
7
8
  id: string;
8
- label: string;
9
+ label: ReactNode;
10
+ value?: ReactNode;
9
11
  active?: boolean;
12
+ removeLabel?: string;
10
13
  }
11
14
 
12
- interface FilterChipsProps {
13
- chips: FilterChip[];
14
- onToggle: (id: string) => void;
15
- onRemove: (id: string) => void;
15
+ export interface FilterChipsProps {
16
+ chips: readonly FilterChip[];
17
+ onToggle?: (id: string) => void;
18
+ onRemove?: (id: string) => void;
16
19
  className?: string;
20
+ "aria-label"?: string;
17
21
  }
18
22
 
19
- export function FilterChips({ chips, onToggle, onRemove, className }: FilterChipsProps) {
23
+ export function FilterChips({
24
+ chips,
25
+ onToggle,
26
+ onRemove,
27
+ className,
28
+ "aria-label": ariaLabel = "Applied filters",
29
+ }: FilterChipsProps) {
20
30
  return (
21
- <div className={cn("flex flex-wrap gap-2", className)}>
22
- {chips.map((chip) => (
23
- <button
24
- key={chip.id}
25
- type="button"
26
- onClick={() => onToggle(chip.id)}
27
- className={cn(
28
- "inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
29
- chip.active
30
- ? "bg-ph-brand text-white"
31
- : "bg-ph-muted text-ph-subtle hover:bg-ph-border hover:text-ph-ink"
32
- )}
33
- >
34
- {chip.label}
35
- {chip.active && (
36
- <span
37
- onClick={(e) => {
38
- e.stopPropagation();
39
- onRemove(chip.id);
40
- }}
41
- className="ml-0.5 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-white/20"
42
- >
43
- <IconClose className="h-3 w-3" />
44
- </span>
45
- )}
46
- </button>
47
- ))}
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
+ })}
48
72
  </div>
49
73
  );
50
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
+ }