@voila.dev/ui 1.1.9

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 (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. package/src/styles.css +10 -0
@@ -0,0 +1,96 @@
1
+ import type * as React from "react";
2
+ import {
3
+ InputGroup,
4
+ InputGroupAddon,
5
+ InputGroupInput,
6
+ } from "#/components/ui/input-group.tsx";
7
+ import { cn } from "#/lib/utils.ts";
8
+
9
+ type MoneyInputProps = Omit<
10
+ React.ComponentProps<typeof InputGroupInput>,
11
+ "type" | "value" | "onChange"
12
+ > & {
13
+ /** Raw amount as typed (e.g. `"120"` or `"120.50"`). */
14
+ value: string;
15
+ /** Receives the raw amount string on every change. */
16
+ onValueChange: (value: string) => void;
17
+ /** Selected currency code (e.g. `"EUR"`). */
18
+ currency: string;
19
+ /** Currency codes offered by the select. Defaults to the single `currency`. */
20
+ currencies?: ReadonlyArray<string>;
21
+ /** Receives the selected currency code. Omit when the select is locked. */
22
+ onCurrencyChange?: (currency: string) => void;
23
+ /**
24
+ * Locks the currency select. Defaults to `true` when there is a single
25
+ * currency to choose from, so the platform's single-currency case reads as a
26
+ * disabled selector rather than a bare suffix.
27
+ */
28
+ currencyDisabled?: boolean;
29
+ /** Accessible name for the currency select. */
30
+ currencyLabel: string;
31
+ /** Wrapper class (width/layout), forwarded to the `InputGroup`. */
32
+ className?: string;
33
+ };
34
+
35
+ /**
36
+ * A money amount input: a numeric field with a trailing currency select. The
37
+ * select is disabled by default (the platform settles in a single currency), so
38
+ * it reads as a locked currency selector rather than a static suffix — pass
39
+ * `currencies` + `onCurrencyChange` to make it interactive. The amount is an
40
+ * uncontrolled-string like the underlying `<input type="number">`; the consumer
41
+ * owns parsing/formatting into cents.
42
+ */
43
+ function MoneyInput({
44
+ value,
45
+ onValueChange,
46
+ currency,
47
+ currencies = [currency],
48
+ onCurrencyChange,
49
+ currencyDisabled = currencies.length <= 1,
50
+ currencyLabel,
51
+ className,
52
+ ...props
53
+ }: MoneyInputProps) {
54
+ return (
55
+ <InputGroup
56
+ className={cn(
57
+ "has-disabled:bg-transparent has-disabled:opacity-100 dark:has-disabled:bg-input/30",
58
+ className,
59
+ )}
60
+ data-slot="money-input"
61
+ >
62
+ <InputGroupInput
63
+ type="number"
64
+ inputMode="decimal"
65
+ min="0"
66
+ step="0.5"
67
+ value={value}
68
+ onChange={(event) => onValueChange(event.target.value)}
69
+ {...props}
70
+ />
71
+ <InputGroupAddon align="inline-end">
72
+ <select
73
+ data-slot="money-input-currency"
74
+ aria-label={currencyLabel}
75
+ disabled={currencyDisabled}
76
+ value={currency}
77
+ onChange={(event) => onCurrencyChange?.(event.target.value)}
78
+ className={cn(
79
+ "cursor-pointer appearance-none bg-transparent pr-1 text-sm font-medium text-muted-foreground outline-none",
80
+ // A disabled select must stay fully legible (it is the locked
81
+ // currency label), so keep full opacity and drop the cursor.
82
+ "disabled:cursor-default disabled:opacity-100",
83
+ )}
84
+ >
85
+ {currencies.map((code) => (
86
+ <option key={code} value={code}>
87
+ {code}
88
+ </option>
89
+ ))}
90
+ </select>
91
+ </InputGroupAddon>
92
+ </InputGroup>
93
+ );
94
+ }
95
+
96
+ export { MoneyInput, type MoneyInputProps };
@@ -0,0 +1,120 @@
1
+ import {
2
+ CalendarBlankIcon,
3
+ CalendarDotsIcon,
4
+ ClockIcon,
5
+ } from "@phosphor-icons/react";
6
+ import type * as React from "react";
7
+ import { cn } from "#/lib/utils.ts";
8
+
9
+ type NativeDateFieldProps = Omit<
10
+ React.ComponentProps<"input">,
11
+ "type" | "size"
12
+ > & {
13
+ size?: "sm" | "default";
14
+ /**
15
+ * Classes for the wrapper `<div>` that hosts the leading icon. Use this for
16
+ * layout and width (e.g. `w-full`, `w-48`); `className` styles the inner
17
+ * `<input>` itself, matching the rest of the kit's form controls.
18
+ */
19
+ wrapperClassName?: string;
20
+ };
21
+
22
+ /**
23
+ * Shared chrome for the native date/time/datetime-local inputs: the kit's
24
+ * Input field styling plus a leading icon (mirroring DatePicker's trigger)
25
+ * and click-anywhere-to-open via `showPicker()`. The WebKit indicator is
26
+ * hidden — the whole field is the affordance, like the composed DatePicker.
27
+ */
28
+ function NativeDateField({
29
+ type,
30
+ slot,
31
+ icon,
32
+ className,
33
+ wrapperClassName,
34
+ size = "default",
35
+ onClick,
36
+ ...props
37
+ }: NativeDateFieldProps & {
38
+ type: "date" | "time" | "datetime-local";
39
+ slot: string;
40
+ icon: React.ReactNode;
41
+ }) {
42
+ return (
43
+ <div
44
+ className={cn(
45
+ "group/native-date-picker relative w-fit has-[input:disabled]:opacity-50",
46
+ wrapperClassName,
47
+ )}
48
+ data-slot={`${slot}-wrapper`}
49
+ data-size={size}
50
+ >
51
+ <span
52
+ className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground select-none [&_svg]:size-4"
53
+ aria-hidden="true"
54
+ data-slot={`${slot}-icon`}
55
+ >
56
+ {icon}
57
+ </span>
58
+ <input
59
+ type={type}
60
+ data-slot={slot}
61
+ data-size={size}
62
+ // `text-base` then `md:text-sm` is the same iOS zoom guard as Input.
63
+ className={cn(
64
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent py-1 pr-2.5 pl-8 text-base transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),12px)] data-[size=sm]:py-0.5 md:text-sm dark:scheme-dark dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&::-webkit-calendar-picker-indicator]:hidden",
65
+ className,
66
+ )}
67
+ onClick={(event) => {
68
+ onClick?.(event);
69
+ if (event.defaultPrevented) return;
70
+ const input = event.currentTarget;
71
+ // Chrome/Edge/Firefox/Safari 16+; throws outside a user gesture.
72
+ if (typeof input.showPicker === "function") {
73
+ try {
74
+ input.showPicker();
75
+ } catch {
76
+ // Unsupported context (e.g. cross-origin iframe) — the field
77
+ // still works through its native segment editing.
78
+ }
79
+ }
80
+ }}
81
+ {...props}
82
+ />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function NativeDatePicker(props: NativeDateFieldProps) {
88
+ return (
89
+ <NativeDateField
90
+ type="date"
91
+ slot="native-date-picker"
92
+ icon={<CalendarBlankIcon />}
93
+ {...props}
94
+ />
95
+ );
96
+ }
97
+
98
+ function NativeTimePicker(props: NativeDateFieldProps) {
99
+ return (
100
+ <NativeDateField
101
+ type="time"
102
+ slot="native-time-picker"
103
+ icon={<ClockIcon />}
104
+ {...props}
105
+ />
106
+ );
107
+ }
108
+
109
+ function NativeDateTimePicker(props: NativeDateFieldProps) {
110
+ return (
111
+ <NativeDateField
112
+ type="datetime-local"
113
+ slot="native-date-time-picker"
114
+ icon={<CalendarDotsIcon />}
115
+ {...props}
116
+ />
117
+ );
118
+ }
119
+
120
+ export { NativeDatePicker, NativeDateTimePicker, NativeTimePicker };
@@ -0,0 +1,74 @@
1
+ import { CaretDownIcon } from "@phosphor-icons/react";
2
+ import type * as React from "react";
3
+ import { cn } from "#/lib/utils.ts";
4
+
5
+ type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
6
+ size?: "sm" | "default";
7
+ /**
8
+ * Classes for the wrapper `<div>` that hosts the caret overlay. Use this for
9
+ * layout and width (e.g. `w-full`, `w-48`); `className` styles the inner
10
+ * `<select>` itself, matching the rest of the kit's form controls.
11
+ */
12
+ wrapperClassName?: string;
13
+ };
14
+
15
+ function NativeSelect({
16
+ className,
17
+ wrapperClassName,
18
+ size = "default",
19
+ ...props
20
+ }: NativeSelectProps) {
21
+ return (
22
+ <div
23
+ className={cn(
24
+ "group/native-select relative w-full has-[select:disabled]:opacity-50",
25
+ wrapperClassName,
26
+ )}
27
+ data-slot="native-select-wrapper"
28
+ data-size={size}
29
+ >
30
+ <select
31
+ data-slot="native-select"
32
+ data-size={size}
33
+ className={cn(
34
+ "h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:scheme-dark dark:bg-input/30 dark:hover:bg-input/50 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
35
+ className,
36
+ )}
37
+ {...props}
38
+ />
39
+ <CaretDownIcon
40
+ className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
41
+ aria-hidden="true"
42
+ data-slot="native-select-icon"
43
+ />
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function NativeSelectOption({
49
+ className,
50
+ ...props
51
+ }: React.ComponentProps<"option">) {
52
+ return (
53
+ <option
54
+ data-slot="native-select-option"
55
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
56
+ {...props}
57
+ />
58
+ );
59
+ }
60
+
61
+ function NativeSelectOptGroup({
62
+ className,
63
+ ...props
64
+ }: React.ComponentProps<"optgroup">) {
65
+ return (
66
+ <optgroup
67
+ data-slot="native-select-optgroup"
68
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
@@ -0,0 +1,183 @@
1
+ import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
2
+ import { CaretDownIcon } from "@phosphor-icons/react";
3
+ import { cva } from "#/lib/cva.ts";
4
+ import { cn } from "#/lib/utils.ts";
5
+
6
+ function NavigationMenu({
7
+ align = "start",
8
+ alignOffset = 0,
9
+ side = "bottom",
10
+ sideOffset = 8,
11
+ collisionPadding,
12
+ className,
13
+ children,
14
+ ...props
15
+ }: NavigationMenuPrimitive.Root.Props &
16
+ Pick<
17
+ NavigationMenuPrimitive.Positioner.Props,
18
+ "align" | "alignOffset" | "side" | "sideOffset" | "collisionPadding"
19
+ >) {
20
+ return (
21
+ <NavigationMenuPrimitive.Root
22
+ data-slot="navigation-menu"
23
+ className={cn(
24
+ "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
25
+ className,
26
+ )}
27
+ {...props}
28
+ >
29
+ {children}
30
+ <NavigationMenuPositioner
31
+ align={align}
32
+ alignOffset={alignOffset}
33
+ side={side}
34
+ sideOffset={sideOffset}
35
+ collisionPadding={collisionPadding}
36
+ />
37
+ </NavigationMenuPrimitive.Root>
38
+ );
39
+ }
40
+
41
+ function NavigationMenuList({
42
+ className,
43
+ ...props
44
+ }: NavigationMenuPrimitive.List.Props) {
45
+ return (
46
+ <NavigationMenuPrimitive.List
47
+ data-slot="navigation-menu-list"
48
+ className={cn(
49
+ "group flex flex-1 list-none items-center justify-center gap-0",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function NavigationMenuItem({
58
+ className,
59
+ ...props
60
+ }: NavigationMenuPrimitive.Item.Props) {
61
+ return (
62
+ <NavigationMenuPrimitive.Item
63
+ data-slot="navigation-menu-item"
64
+ className={cn("relative", className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ const navigationMenuTriggerStyle = cva({
71
+ base: "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-popup-open:focus-visible:bg-muted",
72
+ });
73
+
74
+ function NavigationMenuTrigger({
75
+ className,
76
+ children,
77
+ ...props
78
+ }: NavigationMenuPrimitive.Trigger.Props) {
79
+ return (
80
+ <NavigationMenuPrimitive.Trigger
81
+ data-slot="navigation-menu-trigger"
82
+ className={cn(navigationMenuTriggerStyle(), "group", className)}
83
+ {...props}
84
+ >
85
+ {children}{" "}
86
+ <CaretDownIcon
87
+ className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180"
88
+ aria-hidden="true"
89
+ />
90
+ </NavigationMenuPrimitive.Trigger>
91
+ );
92
+ }
93
+
94
+ function NavigationMenuContent({
95
+ className,
96
+ ...props
97
+ }: NavigationMenuPrimitive.Content.Props) {
98
+ return (
99
+ <NavigationMenuPrimitive.Content
100
+ data-slot="navigation-menu-content"
101
+ className={cn(
102
+ "h-full w-auto p-1 transition-[opacity,transform,translate] duration-350 ease-fluid data-starting-style:opacity-0 data-starting-style:data-[activation-direction=left]:translate-x-[-50%] data-starting-style:data-[activation-direction=right]:translate-x-[50%] data-ending-style:opacity-0 data-ending-style:data-[activation-direction=left]:translate-x-[50%] data-ending-style:data-[activation-direction=right]:translate-x-[-50%] motion-reduce:transition-none **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
103
+ className,
104
+ )}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ function NavigationMenuPositioner({
111
+ className,
112
+ side = "bottom",
113
+ sideOffset = 8,
114
+ align = "start",
115
+ alignOffset = 0,
116
+ ...props
117
+ }: NavigationMenuPrimitive.Positioner.Props) {
118
+ return (
119
+ <NavigationMenuPrimitive.Portal>
120
+ <NavigationMenuPrimitive.Positioner
121
+ side={side}
122
+ sideOffset={sideOffset}
123
+ align={align}
124
+ alignOffset={alignOffset}
125
+ className={cn(
126
+ "isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-350 ease-fluid motion-reduce:transition-none data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
127
+ className,
128
+ )}
129
+ {...props}
130
+ >
131
+ <NavigationMenuPrimitive.Popup className="relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-350 ease-fluid outline-none motion-reduce:transition-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-[ease] data-starting-style:scale-90 data-starting-style:opacity-0">
132
+ <NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
133
+ </NavigationMenuPrimitive.Popup>
134
+ </NavigationMenuPrimitive.Positioner>
135
+ </NavigationMenuPrimitive.Portal>
136
+ );
137
+ }
138
+
139
+ function NavigationMenuLink({
140
+ className,
141
+ ...props
142
+ }: NavigationMenuPrimitive.Link.Props) {
143
+ return (
144
+ <NavigationMenuPrimitive.Link
145
+ data-slot="navigation-menu-link"
146
+ className={cn(
147
+ "flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus-visible:bg-muted [&_svg:not([class*='size-'])]:size-4",
148
+ className,
149
+ )}
150
+ {...props}
151
+ />
152
+ );
153
+ }
154
+
155
+ function NavigationMenuIndicator({
156
+ className,
157
+ ...props
158
+ }: NavigationMenuPrimitive.Icon.Props) {
159
+ return (
160
+ <NavigationMenuPrimitive.Icon
161
+ data-slot="navigation-menu-indicator"
162
+ className={cn(
163
+ "top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
164
+ className,
165
+ )}
166
+ {...props}
167
+ >
168
+ <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
169
+ </NavigationMenuPrimitive.Icon>
170
+ );
171
+ }
172
+
173
+ export {
174
+ NavigationMenu,
175
+ NavigationMenuContent,
176
+ NavigationMenuIndicator,
177
+ NavigationMenuItem,
178
+ NavigationMenuLink,
179
+ NavigationMenuList,
180
+ NavigationMenuPositioner,
181
+ NavigationMenuTrigger,
182
+ navigationMenuTriggerStyle,
183
+ };
@@ -0,0 +1,150 @@
1
+ import {
2
+ CaretLeftIcon,
3
+ CaretRightIcon,
4
+ DotsThreeIcon,
5
+ } from "@phosphor-icons/react";
6
+ import type * as React from "react";
7
+ import { Button } from "#/components/ui/button.tsx";
8
+ import { cn } from "#/lib/utils.ts";
9
+
10
+ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
11
+ return (
12
+ <nav
13
+ // Sentence case to match the kit's other nav labels; spread-overridable
14
+ // (e.g. a French `aria-label="Pagination"`) since `{...props}` wins.
15
+ aria-label="Pagination"
16
+ data-slot="pagination"
17
+ className={cn("mx-auto flex w-full justify-center", className)}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ function PaginationContent({
24
+ className,
25
+ ...props
26
+ }: React.ComponentProps<"ul">) {
27
+ return (
28
+ <ul
29
+ data-slot="pagination-content"
30
+ className={cn("flex items-center gap-0.5", className)}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function PaginationItem({ ...props }: React.ComponentProps<"li">) {
37
+ return <li data-slot="pagination-item" {...props} />;
38
+ }
39
+
40
+ type PaginationLinkProps = {
41
+ isActive?: boolean;
42
+ /**
43
+ * Render the link as non-interactive (e.g. Previous on the first page):
44
+ * `aria-disabled`, removed from the tab order, and dimmed. Maps to the kit's
45
+ * `aria-disabled:` styling idiom rather than a dead `href`.
46
+ */
47
+ isDisabled?: boolean;
48
+ } & Pick<React.ComponentProps<typeof Button>, "size" | "variant"> &
49
+ React.ComponentProps<"a">;
50
+
51
+ function PaginationLink({
52
+ className,
53
+ isActive,
54
+ isDisabled,
55
+ size = "icon",
56
+ variant,
57
+ ...props
58
+ }: PaginationLinkProps) {
59
+ return (
60
+ <Button
61
+ // `variant` overrides the active/inactive default so consumers can opt
62
+ // into e.g. a `secondary` active page without re-implementing the link.
63
+ variant={variant ?? (isActive ? "outline" : "ghost")}
64
+ size={size}
65
+ className={cn(
66
+ "aria-disabled:pointer-events-none aria-disabled:opacity-50",
67
+ className,
68
+ )}
69
+ nativeButton={false}
70
+ render={
71
+ <a
72
+ data-slot="pagination-link"
73
+ data-active={isActive}
74
+ aria-current={isActive ? "page" : undefined}
75
+ {...props}
76
+ aria-disabled={isDisabled || undefined}
77
+ tabIndex={isDisabled ? -1 : props.tabIndex}
78
+ />
79
+ }
80
+ />
81
+ );
82
+ }
83
+
84
+ function PaginationPrevious({
85
+ className,
86
+ text = "Previous",
87
+ ...props
88
+ }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
89
+ return (
90
+ <PaginationLink
91
+ aria-label="Go to previous page"
92
+ size="default"
93
+ className={cn("pl-1.5!", className)}
94
+ {...props}
95
+ >
96
+ <CaretLeftIcon data-icon="inline-start" />
97
+ <span className="hidden sm:block">{text}</span>
98
+ </PaginationLink>
99
+ );
100
+ }
101
+
102
+ function PaginationNext({
103
+ className,
104
+ text = "Next",
105
+ ...props
106
+ }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
107
+ return (
108
+ <PaginationLink
109
+ aria-label="Go to next page"
110
+ size="default"
111
+ className={cn("pr-1.5!", className)}
112
+ {...props}
113
+ >
114
+ <span className="hidden sm:block">{text}</span>
115
+ <CaretRightIcon data-icon="inline-end" />
116
+ </PaginationLink>
117
+ );
118
+ }
119
+
120
+ function PaginationEllipsis({
121
+ className,
122
+ ...props
123
+ }: React.ComponentProps<"span">) {
124
+ return (
125
+ // Decorative gap indicator: `aria-hidden` hides it from the a11y tree, so
126
+ // it carries no sr-only text (the old one was unreachable inside the hidden
127
+ // subtree). The surrounding page links convey the skipped range.
128
+ <span
129
+ aria-hidden
130
+ data-slot="pagination-ellipsis"
131
+ className={cn(
132
+ "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
133
+ className,
134
+ )}
135
+ {...props}
136
+ >
137
+ <DotsThreeIcon />
138
+ </span>
139
+ );
140
+ }
141
+
142
+ export {
143
+ Pagination,
144
+ PaginationContent,
145
+ PaginationEllipsis,
146
+ PaginationItem,
147
+ PaginationLink,
148
+ PaginationNext,
149
+ PaginationPrevious,
150
+ };