@ponti-studios/ui 0.7.0 → 0.8.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 (44) hide show
  1. package/README.md +13 -0
  2. package/package.json +22 -20
  3. package/src/components/data-display/accordion.tsx +1 -1
  4. package/src/components/data-display/sort-controls.tsx +3 -3
  5. package/src/components/feedback/index.ts +0 -2
  6. package/src/components/feedback/update-guard.tsx +5 -1
  7. package/src/components/filters/filter-select.tsx +1 -1
  8. package/src/components/forms/date-month-select.tsx +12 -3
  9. package/src/components/forms/date-picker.tsx +16 -12
  10. package/src/components/forms/drop-zone.tsx +1 -1
  11. package/src/components/forms/index.ts +4 -8
  12. package/src/components/forms/passkey-management.tsx +4 -10
  13. package/src/components/forms/radio-group.tsx +8 -4
  14. package/src/components/forms/select.tsx +1 -1
  15. package/src/components/forms/text-field.tsx +40 -5
  16. package/src/components/navigation/app-navigation.stories.tsx +0 -1
  17. package/src/components/navigation/app-navigation.tsx +67 -61
  18. package/src/components/navigation/index.ts +7 -4
  19. package/src/components/navigation/pagination-controls.stories.tsx +1 -1
  20. package/src/components/navigation/pagination-controls.tsx +10 -14
  21. package/src/components/overlays/dropdown-menu.stories.tsx +2 -2
  22. package/src/components/primitives/card.tsx +6 -10
  23. package/src/components/primitives/label.stories.tsx +0 -18
  24. package/src/components/primitives/label.tsx +12 -12
  25. package/src/constants/chart-colors.ts +16 -16
  26. package/src/hooks/use-media-query.ts +5 -1
  27. package/src/styles/animations.css +0 -22
  28. package/src/styles/index.css +60 -6
  29. package/src/styles/tokens/color-systems.stories.tsx +2 -1
  30. package/src/styles/tokens/generated/light.colors.generated.ts +3 -3
  31. package/src/styles/tokens/generated/light.css +3 -3
  32. package/src/styles/tokens/source/light.tokens.json +3 -3
  33. package/src/styles/tokens/style-dictionary.light-storybook.json +16 -0
  34. package/src/components/feedback/file-upload-status-badge.stories.tsx +0 -28
  35. package/src/components/feedback/file-upload-status-badge.tsx +0 -33
  36. package/src/components/forms/field.stories.tsx +0 -62
  37. package/src/components/forms/field.tsx +0 -65
  38. package/src/components/forms/field.types.ts +0 -7
  39. package/src/components/forms/group-by-select.stories.tsx +0 -26
  40. package/src/components/forms/group-by-select.tsx +0 -47
  41. package/src/components/navigation/route-link.stories.tsx +0 -30
  42. package/src/components/navigation/route-link.tsx +0 -42
  43. package/src/hooks/use-derived-filter-state.stories.tsx +0 -136
  44. package/src/hooks/use-filter-state.stories.tsx +0 -102
package/README.md CHANGED
@@ -11,6 +11,19 @@ The design system is documented in `docs/`:
11
11
  - [Patterns](./docs/patterns.md) — screen composition rules
12
12
  - [Review gates](./docs/review-gates.md) — pass/fail review criteria
13
13
 
14
+ ## Development
15
+
16
+ Common development workflows are available through the `Makefile`:
17
+
18
+ ```bash
19
+ make check # tokens, source contracts, and typecheck
20
+ make tokens-build # regenerate token artifacts
21
+ make storybook # build Storybook themes and start Storybook
22
+ make test-a11y # run light and dark accessibility tests
23
+ ```
24
+
25
+ The existing `pnpm run` scripts delegate to these targets for compatibility.
26
+
14
27
  ## Release
15
28
 
16
29
  Releases are owned by [release-please](https://github.com/googleapis/release-please), not by hand-edited version numbers or manually pushed tags. The `.github/workflows/publish.yml` workflow runs after a successful `validate` push to `main`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -82,18 +82,20 @@
82
82
  "registry": "https://registry.npmjs.org"
83
83
  },
84
84
  "scripts": {
85
- "build": "tsc --noEmit",
85
+ "build": "make build",
86
86
  "dev": "vite",
87
- "storybook": "style-dictionary build --config src/styles/tokens/style-dictionary.dark-storybook.json && storybook dev -p 6006",
88
- "check:source": "node src/styles/tokens/scripts/check-source.mjs",
89
- "test": "vitest --run --config vitest.config.ts",
90
- "test:a11y": "vitest --run --config vitest.config.ts",
91
- "tokens:build": "style-dictionary build --config src/styles/tokens/style-dictionary.foundations.json && style-dictionary build --config src/styles/tokens/style-dictionary.light.json && style-dictionary build --config src/styles/tokens/style-dictionary.dark.json && style-dictionary build --config src/styles/tokens/style-dictionary.dark-storybook.json",
92
- "tokens:check": "pnpm run tokens:build && node src/styles/tokens/scripts/check-foundations.mjs && git diff --exit-code -- src/styles/tokens/generated",
93
- "typecheck": "tsc --noEmit",
94
- "lint": "oxlint src --fix",
95
- "format": "oxfmt src --write",
96
- "prepack": "pnpm run tokens:check && pnpm run check:source && pnpm run typecheck"
87
+ "storybook": "make storybook",
88
+ "check:source": "make check-source",
89
+ "test": "make test",
90
+ "test:a11y": "make test-a11y",
91
+ "test:a11y:light": "make test-a11y-light",
92
+ "test:a11y:dark": "make test-a11y-dark",
93
+ "tokens:build": "make tokens-build",
94
+ "tokens:check": "make tokens-check",
95
+ "typecheck": "make typecheck",
96
+ "lint": "make lint",
97
+ "format": "make format",
98
+ "prepack": "make prepack"
97
99
  },
98
100
  "dependencies": {
99
101
  "@base-ui/react": "^1.6.0",
@@ -107,13 +109,13 @@
107
109
  },
108
110
  "devDependencies": {
109
111
  "@chromatic-com/storybook": "^5.2.1",
110
- "@storybook/addon-a11y": "10.5.3",
111
- "@storybook/addon-docs": "^10.5.3",
112
- "@storybook/addon-links": "^10.5.3",
113
- "@storybook/addon-onboarding": "^10.5.3",
114
- "@storybook/addon-themes": "10.5.3",
115
- "@storybook/addon-vitest": "10.5.3",
116
- "@storybook/react-vite": "^10.5.3",
112
+ "@storybook/addon-a11y": "10.5.6",
113
+ "@storybook/addon-docs": "10.5.6",
114
+ "@storybook/addon-links": "10.5.6",
115
+ "@storybook/addon-onboarding": "10.5.6",
116
+ "@storybook/addon-themes": "10.5.6",
117
+ "@storybook/addon-vitest": "10.5.6",
118
+ "@storybook/react-vite": "10.5.6",
117
119
  "@tailwindcss/vite": "^4.3.3",
118
120
  "@types/node": "^26.1.1",
119
121
  "@types/react": "^19.2.17",
@@ -127,7 +129,7 @@
127
129
  "react": "^19.2.7",
128
130
  "react-dom": "^19.2.7",
129
131
  "react-native": "0.86.0",
130
- "storybook": "^10.5.3",
132
+ "storybook": "10.5.6",
131
133
  "style-dictionary": "5.5.0",
132
134
  "tailwindcss": "^4.3.3",
133
135
  "typescript": "6.0.3",
@@ -46,7 +46,7 @@ function AccordionTrigger({
46
46
  <AccordionPrimitive.Trigger
47
47
  data-slot="accordion-trigger"
48
48
  className={cn(
49
- "flex w-full flex-1 items-center justify-between gap-4 px-4 py-3 text-left text-sm font-semibold disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
49
+ "text-muted-foreground flex w-full flex-1 items-center justify-between gap-4 px-4 py-3 text-left text-sm font-semibold disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
50
50
  className,
51
51
  )}
52
52
  {...props}
@@ -48,10 +48,10 @@ export function SortControls({
48
48
  </Button>
49
49
  </DropdownMenuTrigger>
50
50
  <DropdownMenuContent className="w-80 space-y-2 p-2">
51
- <DropdownMenuLabel>Define Sort Order</DropdownMenuLabel>
52
- <DropdownMenuSeparator />
53
-
54
51
  <DropdownMenuGroup className="space-y-1">
52
+ <DropdownMenuLabel>Define Sort Order</DropdownMenuLabel>
53
+ <DropdownMenuSeparator />
54
+
55
55
  {sortOptions.map((sort, index) => {
56
56
  const usedFields = sortOptions
57
57
  .filter((_, i) => i !== index)
@@ -3,8 +3,6 @@ export { EmptyState } from "./empty-state";
3
3
  export type { EmptyStateProps } from "./empty-state";
4
4
  export { FileUploadStatus } from "./file-upload-status";
5
5
  export type { FileUploadState, FileUploadStats } from "./file-upload-status";
6
- export { FileUploadStatusBadge } from "./file-upload-status-badge";
7
- export type { FileUploadStatusValue } from "./file-upload-status-badge";
8
6
  export { Progress } from "./progress";
9
7
  export { ProgressBar } from "./progress-bar";
10
8
  export type { ProgressBarProps } from "./progress-bar";
@@ -158,6 +158,10 @@ function getOnlineSnapshot() {
158
158
  return navigator.onLine;
159
159
  }
160
160
 
161
+ function getOnlineServerSnapshot() {
162
+ return true;
163
+ }
164
+
161
165
  export interface UpdateGuardCopy {
162
166
  offlineReady?: string;
163
167
  newContentAvailable?: string;
@@ -192,7 +196,7 @@ function UpdateGuardClient({
192
196
  updateServiceWorker,
193
197
  } = useRegisterSW({ serviceWorkerPath });
194
198
 
195
- const isOnline = useSyncExternalStore(subscribeOnline, getOnlineSnapshot, () => true);
199
+ const isOnline = useSyncExternalStore(subscribeOnline, getOnlineSnapshot, getOnlineServerSnapshot);
196
200
 
197
201
  const closePrompt = () => {
198
202
  setOfflineReady(false);
@@ -44,7 +44,7 @@ export function FilterSelect({
44
44
  );
45
45
 
46
46
  return (
47
- <div className={cn("space-y-2 min-w-36", className)}>
47
+ <div className={cn("space-y-2 min-w-36 bg-background", className)}>
48
48
  <Label htmlFor={selectId}>{label}</Label>
49
49
  <Select value={value} onValueChange={onChange} items={items}>
50
50
  <SelectTrigger
@@ -1,6 +1,7 @@
1
1
  import { Calendar } from "lucide-react";
2
2
  import { useMemo } from "react";
3
3
 
4
+ import { cn } from "../../lib/utils";
4
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
5
6
 
6
7
  interface MonthOption {
@@ -40,13 +41,21 @@ export function DateMonthSelect({
40
41
  }, [monthsBack]);
41
42
 
42
43
  return (
43
- <div className="flex items-center gap-2">
44
- <Calendar className="size-4" />
44
+ <div className="flex items-center">
45
+ <div className="flex items-center justify-center size-10 border rounded-l-xl bg-surface-2 text-text-1">
46
+ <Calendar className="size-4" />
47
+ </div>
45
48
  <Select
46
49
  value={selectedMonthYear}
47
50
  onValueChange={(v: string | null) => v != null && onMonthChange(v)}
48
51
  >
49
- <SelectTrigger className={className} aria-label={placeholder}>
52
+ <SelectTrigger
53
+ className={cn(
54
+ "rounded-r-xl rounded-l-none min-h-10 max-h-10 bg-surface-2 text-text-1 border-l-0",
55
+ className,
56
+ )}
57
+ aria-label={placeholder}
58
+ >
50
59
  <SelectValue placeholder={placeholder} />
51
60
  </SelectTrigger>
52
61
  <SelectContent>
@@ -1,9 +1,9 @@
1
1
  import { format } from "date-fns";
2
2
  import { CalendarIcon } from "lucide-react";
3
3
 
4
- import { Button } from "../primitives/button";
5
4
  import { Calendar } from "../forms/calendar";
6
5
  import { Popover, PopoverContent, PopoverTrigger } from "../overlays/popover";
6
+ import { Button } from "../primitives/button";
7
7
 
8
8
  export interface DatePickerProps {
9
9
  value: Date | undefined;
@@ -40,17 +40,21 @@ export function DatePicker({
40
40
  </label>
41
41
  ) : null}
42
42
  <Popover>
43
- <PopoverTrigger asChild>
44
- <Button
45
- id={id}
46
- variant={variant}
47
- disabled={disabled}
48
- className="bg-muted placeholder:text-muted-foreground w-full justify-start text-left font-normal"
49
- >
50
- <CalendarIcon className="mr-2 size-4" />
51
- {value ? format(value, dateFormat) : <span>{placeholder}</span>}
52
- </Button>
53
- </PopoverTrigger>
43
+ <div className="flex w-full items-center">
44
+ <div className="border-border bg-surface-2 text-text-1 flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
45
+ <CalendarIcon className="size-4" aria-hidden />
46
+ </div>
47
+ <PopoverTrigger asChild>
48
+ <Button
49
+ id={id}
50
+ variant={variant}
51
+ disabled={disabled}
52
+ className="border-border bg-surface-2 text-text-1 placeholder:text-muted-foreground min-h-10 max-h-10 w-full justify-start rounded-l-none rounded-r-xl border-l-0 text-left font-normal"
53
+ >
54
+ {value ? format(value, dateFormat) : <span>{placeholder}</span>}
55
+ </Button>
56
+ </PopoverTrigger>
57
+ </div>
54
58
  <PopoverContent className="bg-popover w-auto p-0" align={popoverAlign}>
55
59
  <Calendar mode="single" selected={value} onSelect={onSelect} />
56
60
  </PopoverContent>
@@ -126,7 +126,7 @@ export function DropZone({
126
126
  >
127
127
  <div className="space-y-2 text-center">
128
128
  <p className="text-muted-foreground">
129
- {label} <span className="text-foreground font-medium">click to browse</span>
129
+ {label} <span className="text-accent font-medium">click to browse</span>
130
130
  </p>
131
131
  {helpText ? <p className="text-muted-foreground text-sm">{helpText}</p> : null}
132
132
  </div>
@@ -1,17 +1,13 @@
1
+ export { FilterSelect } from "../filters/filter-select";
2
+ export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
1
3
  export { Calendar } from "./calendar";
2
4
  export type { CalendarProps } from "./calendar";
3
- export { DatePicker } from "./date-picker";
4
- export type { DatePickerProps } from "./date-picker";
5
5
  export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
6
6
  export type { DateMonthSelectProps } from "./date-month-select";
7
+ export { DatePicker } from "./date-picker";
8
+ export type { DatePickerProps } from "./date-picker";
7
9
  export { DropZone } from "./drop-zone";
8
10
  export type { DropZoneProps } from "./drop-zone";
9
- export { Field } from "./field";
10
- export type { FieldProps } from "./field";
11
- export { FilterSelect } from "../filters/filter-select";
12
- export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
13
- export { GroupBySelect } from "./group-by-select";
14
- export type { GroupBySelectProps } from "./group-by-select";
15
11
  export { Input } from "./input";
16
12
  export { PasskeyManagement } from "./passkey-management";
17
13
  export type { PasskeyManagementProps, PasskeyRecord } from "./passkey-management";
@@ -68,17 +68,11 @@ export function PasskeyManagement({
68
68
  return (
69
69
  <section aria-labelledby="passkey-heading" className="space-y-4">
70
70
  <div className="flex items-center justify-between">
71
- <div>
72
- <h2 id="passkey-heading" className="text-foreground text-sm font-medium">
73
- Passkeys
74
- </h2>
75
- <p className="text-muted-foreground text-sm">
76
- Sign in without a password using biometrics or a security key.
77
- </p>
78
- </div>
71
+ <h2 id="passkey-heading" className="text-foreground text-sm font-medium">
72
+ Passkeys
73
+ </h2>
79
74
  <Button
80
75
  type="button"
81
- variant="outline"
82
76
  size="sm"
83
77
  onClick={() => void handleAdd()}
84
78
  disabled={adding}
@@ -107,7 +101,7 @@ export function PasskeyManagement({
107
101
  {passkeys.map((pk) => (
108
102
  <li
109
103
  key={pk.id}
110
- className="border-border flex items-center justify-between border px-4 py-3 text-sm"
104
+ className="border-border rounded flex items-center justify-between border px-4 py-3 text-sm"
111
105
  >
112
106
  <div className="flex items-center gap-3">
113
107
  <KeyRound className="text-muted-foreground size-4 shrink-0" aria-hidden />
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
4
3
  import { Radio as RadioPrimitive } from "@base-ui/react/radio";
4
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
5
5
  import { Circle } from "lucide-react";
6
6
  import * as React from "react";
7
7
 
@@ -23,13 +23,17 @@ const RadioGroupItem = React.forwardRef<
23
23
  <RadioPrimitive.Root
24
24
  ref={ref}
25
25
  className={cn(
26
- "border-primary bg-background text-foreground focus-visible:border-ring focus-visible:bg-muted/25 focus-visible:ring-ring aspect-square h-6 w-6 rounded-full border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
26
+ "border-primary bg-background focus-visible:border-ring focus-visible:bg-muted/25 focus-visible:ring-ring aspect-square size-6 rounded-full border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
27
+ "flex items-center justify-center",
27
28
  className,
28
29
  )}
29
30
  {...props}
30
31
  >
31
- <RadioPrimitive.Indicator className="flex items-center justify-center">
32
- <Circle className="fill-primary h-4 w-4" />
32
+ <RadioPrimitive.Indicator
33
+ keepMounted
34
+ className="data-unchecked:opacity-0 data-checked:opacity-100 data-starting-style:animate-fade-in data-ending-style:animate-fade-out"
35
+ >
36
+ <Circle className="fill-primary size-5" />
33
37
  </RadioPrimitive.Indicator>
34
38
  </RadioPrimitive.Root>
35
39
  );
@@ -71,7 +71,7 @@ function SelectContent({
71
71
  <SelectPrimitive.Popup
72
72
  data-slot="select-content"
73
73
  className={cn(
74
- "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 relative z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 outline-none",
74
+ "bg-background text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:zoom-out-95 data-open:zoom-in-95 relative z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 outline-none",
75
75
  className,
76
76
  )}
77
77
  {...props}
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
 
3
- import { Field } from "./field";
3
+ import { cn } from "../../lib/utils";
4
4
  import { Input } from "./input";
5
5
  import type { TextFieldBaseProps, TextFieldType } from "./text-field.types";
6
6
 
@@ -26,14 +26,49 @@ function TextField({
26
26
  type = "text",
27
27
  ...inputProps
28
28
  }: TextFieldProps) {
29
+ const generatedId = React.useId();
30
+ const inputId = id ?? generatedId;
31
+ const descriptionId = `${inputId}-description`;
32
+ const errorId = `${inputId}-error`;
33
+
29
34
  if (!label && !helpText && !error) {
30
- return <Input className={className} disabled={disabled} type={type} {...inputProps} />;
35
+ return (
36
+ <Input id={inputId} className={className} disabled={disabled} type={type} {...inputProps} />
37
+ );
31
38
  }
32
39
 
33
40
  return (
34
- <Field id={id} label={label} helpText={helpText} error={error}>
35
- <Input className={className} disabled={disabled} type={type} {...inputProps} />
36
- </Field>
41
+ <div className="flex flex-col gap-1.5">
42
+ {label && (
43
+ <label
44
+ htmlFor={inputId}
45
+ className={cn(
46
+ "text-foreground text-sm font-medium",
47
+ inputProps.required && "after:text-destructive-text after:ml-0.5 after:content-['*']",
48
+ )}
49
+ >
50
+ {label}
51
+ </label>
52
+ )}
53
+ <Input
54
+ id={inputId}
55
+ className={className}
56
+ disabled={disabled}
57
+ type={type}
58
+ aria-describedby={error ? errorId : helpText ? descriptionId : undefined}
59
+ aria-invalid={error ? true : undefined}
60
+ {...inputProps}
61
+ />
62
+ {error ? (
63
+ <p id={errorId} role="alert" className="text-destructive-text text-sm">
64
+ {error}
65
+ </p>
66
+ ) : helpText ? (
67
+ <p id={descriptionId} className="text-muted-foreground text-sm">
68
+ {helpText}
69
+ </p>
70
+ ) : null}
71
+ </div>
37
72
  );
38
73
  }
39
74
 
@@ -11,7 +11,6 @@ const meta: Meta<typeof AppNavigation> = {
11
11
  layout: "fullscreen",
12
12
  },
13
13
  };
14
-
15
14
  export default meta;
16
15
  type Story = StoryObj<typeof meta>;
17
16
 
@@ -1,8 +1,10 @@
1
1
  import { Menu } from "lucide-react";
2
+ import type { ComponentPropsWithoutRef } from "react";
2
3
  import * as React from "react";
3
4
 
4
- import { Button } from "../primitives/button";
5
+ import { cn } from "../../lib/utils";
5
6
  import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "../overlays/sheet";
7
+ import { Button } from "../primitives/button";
6
8
 
7
9
  export interface AppNavigationLink {
8
10
  href: string;
@@ -24,15 +26,47 @@ export interface AppNavigationProps {
24
26
  ariaLabel?: string;
25
27
  /** Current pathname used to highlight the active link. */
26
28
  activeHref?: string;
27
- /** A router link component. Native anchors are used when omitted. */
28
- linkComponent?: React.ElementType;
29
- /** The prop used by the link component for its destination. */
30
- linkProp?: "href" | "to";
31
- /** Props shared by every link, such as a router's prefetch hint. */
32
- linkProps?: Record<string, unknown>;
33
29
  }
34
30
 
35
- export function AppNavigation({
31
+ interface NavigationLinkOwnProps {
32
+ active?: boolean;
33
+ brand?: boolean;
34
+ }
35
+
36
+ export type NavigationLinkProps = NavigationLinkOwnProps &
37
+ Omit<ComponentPropsWithoutRef<"a">, keyof NavigationLinkOwnProps>;
38
+
39
+ function NavigationLink({
40
+ children,
41
+ active = false,
42
+ brand = false,
43
+ className,
44
+ ...props
45
+ }: NavigationLinkProps) {
46
+ return (
47
+ <a
48
+ {...props}
49
+ className={cn(
50
+ "inline-flex h-9 max-h-9 shrink-0 items-center px-3 py-2 text-sm font-medium",
51
+ !brand && "hover:bg-muted hover:text-muted-foreground",
52
+ className,
53
+ )}
54
+ aria-current={active ? "page" : undefined}
55
+ >
56
+ {children}
57
+ </a>
58
+ );
59
+ }
60
+
61
+ function NavigationCta({ href, label }: AppNavigationCta) {
62
+ return (
63
+ <Button asChild size="md">
64
+ <a href={href}>{label}</a>
65
+ </Button>
66
+ );
67
+ }
68
+
69
+ function NavigationRoot({
36
70
  brand,
37
71
  brandHref = "/",
38
72
  endContent,
@@ -40,9 +74,6 @@ export function AppNavigation({
40
74
  cta,
41
75
  ariaLabel = "Primary navigation",
42
76
  activeHref,
43
- linkComponent: LinkComponent = "a",
44
- linkProp = "href",
45
- linkProps,
46
77
  }: AppNavigationProps) {
47
78
  const [mobileOpen, setMobileOpen] = React.useState(false);
48
79
 
@@ -53,55 +84,25 @@ export function AppNavigation({
53
84
  return href !== "/" && activeHref.startsWith(`${href}/`);
54
85
  };
55
86
 
56
- const createLink = ({
57
- href,
58
- children,
59
- active = false,
60
- variant,
61
- brand = false,
62
- }: {
63
- href: string;
64
- children: React.ReactNode;
65
- active?: boolean;
66
- variant?: AppNavigationCta["variant"];
67
- brand?: boolean;
68
- }) =>
69
- React.createElement(
70
- LinkComponent,
71
- {
72
- ...linkProps,
73
- ...(linkProp === "to" ? { to: href } : { href }),
74
- className: brand
75
- ? "inline-flex min-h-9 shrink-0 items-center rounded-md px-3 py-2 text-sm font-medium"
76
- : variant
77
- ? "inline-flex min-h-9 shrink-0 items-center justify-center rounded-md px-3 py-2 text-sm font-medium"
78
- : "inline-flex min-h-9 shrink-0 items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-muted hover:text-muted-foreground",
79
- "aria-current": active ? "page" : undefined,
80
- "data-variant": variant,
81
- },
82
- children,
83
- );
84
-
85
87
  return (
86
- <header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex w-full justify-center border-b px-4 backdrop-blur">
88
+ <header className="bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 flex w-full justify-center border-b px-4 backdrop-blur">
87
89
  <nav className="w-full max-w-7xl" aria-label={ariaLabel}>
88
90
  <div className="flex min-h-14 items-center justify-between gap-6">
89
91
  <div className="flex min-w-0 items-center">
90
- {brand && createLink({ href: brandHref, children: brand, brand: true })}
92
+ {brand && (
93
+ <NavigationLink href={brandHref} brand>
94
+ {brand}
95
+ </NavigationLink>
96
+ )}
91
97
  </div>
92
98
 
93
99
  <div className="ml-auto hidden min-w-0 items-center gap-1 sm:flex">
94
100
  {links?.map((link) => (
95
- <React.Fragment key={link.href}>
96
- {createLink({ href: link.href, children: link.label, active: isActive(link.href) })}
97
- </React.Fragment>
101
+ <NavigationLink key={link.href} href={link.href} active={isActive(link.href)}>
102
+ {link.label}
103
+ </NavigationLink>
98
104
  ))}
99
- {cta &&
100
- createLink({
101
- href: cta.href,
102
- children: cta.label,
103
- variant: cta.variant ?? "default",
104
- })}
105
+ {cta && <NavigationCta {...cta} />}
105
106
  {endContent}
106
107
  </div>
107
108
 
@@ -122,11 +123,9 @@ export function AppNavigation({
122
123
  <div className="mt-8 flex flex-1 flex-col gap-1">
123
124
  {links?.map((link) => (
124
125
  <SheetClose asChild key={link.href}>
125
- {createLink({
126
- href: link.href,
127
- children: link.label,
128
- active: isActive(link.href),
129
- })}
126
+ <NavigationLink href={link.href} active={isActive(link.href)}>
127
+ {link.label}
128
+ </NavigationLink>
130
129
  </SheetClose>
131
130
  ))}
132
131
  </div>
@@ -135,11 +134,7 @@ export function AppNavigation({
135
134
  <div className="flex flex-col gap-4 border-t pt-4">
136
135
  {cta && (
137
136
  <SheetClose asChild>
138
- {createLink({
139
- href: cta.href,
140
- children: cta.label,
141
- variant: cta.variant ?? "default",
142
- })}
137
+ <NavigationCta {...cta} />
143
138
  </SheetClose>
144
139
  )}
145
140
  {endContent && <div className="flex items-center gap-3">{endContent}</div>}
@@ -153,3 +148,14 @@ export function AppNavigation({
153
148
  </header>
154
149
  );
155
150
  }
151
+
152
+ type NavigationComponent = typeof NavigationRoot & {
153
+ Link: typeof NavigationLink;
154
+ };
155
+
156
+ export const Navigation: NavigationComponent = Object.assign(NavigationRoot, {
157
+ Link: NavigationLink,
158
+ });
159
+
160
+ /** @deprecated Use `Navigation` instead. */
161
+ export const AppNavigation = Navigation;
@@ -1,7 +1,10 @@
1
- export { AppNavigation } from "./app-navigation";
2
- export type { AppNavigationCta, AppNavigationLink, AppNavigationProps } from "./app-navigation";
1
+ export { AppNavigation, Navigation } from "./app-navigation";
2
+ export type {
3
+ AppNavigationCta,
4
+ AppNavigationLink,
5
+ AppNavigationProps,
6
+ NavigationLinkProps,
7
+ } from "./app-navigation";
3
8
  export { PaginationControls } from "./pagination-controls";
4
9
  export type { PaginationControlsProps } from "./pagination-controls";
5
- export { RouteLink } from "./route-link";
6
- export type { RouteLinkProps } from "./route-link";
7
10
  export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
@@ -35,7 +35,7 @@ export const Default: Story = {
35
35
  };
36
36
 
37
37
  export const SinglePage: Story = {
38
- name: "Single page (renders nothing)",
38
+ name: "Single page (disabled controls)",
39
39
  args: { ...baseArgs, totalPages: 1 },
40
40
  render: () => <PaginationPreview totalPages={1} />,
41
41
  };