@ponti-studios/ui 1.0.1 → 1.2.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 (29) hide show
  1. package/package.json +7 -2
  2. package/src/components/feedback/update-guard.tsx +5 -1
  3. package/src/components/filters/active-filters-bar.stories.tsx +34 -36
  4. package/src/components/filters/filter-select.stories.tsx +26 -26
  5. package/src/components/filters/filter-select.tsx +1 -6
  6. package/src/components/filters/filters.stories.tsx +21 -21
  7. package/src/components/filters/filters.tsx +1 -7
  8. package/src/components/filters/search-filter-bar.stories.tsx +8 -10
  9. package/src/components/filters/search-filter-bar.tsx +6 -8
  10. package/src/components/navigation/app-navigation.stories.tsx +5 -1
  11. package/src/components/navigation/app-navigation.tsx +19 -4
  12. package/src/components/overlays/sheet.tsx +22 -7
  13. package/src/hooks/use-derived-filter-state.ts +1 -3
  14. package/src/hooks/use-filter-state.ts +1 -2
  15. package/src/native/card.tsx +36 -0
  16. package/src/native/icon-button.tsx +41 -0
  17. package/src/native/index.ts +27 -0
  18. package/src/native/list-row.tsx +67 -0
  19. package/src/native/text-field.tsx +34 -0
  20. package/src/native/uniwind.d.ts +1 -0
  21. package/src/styles/animations.css +91 -0
  22. package/src/styles/tokens/generated/foundations.css +1 -0
  23. package/src/styles/tokens/generated/foundations.generated.ts +1 -0
  24. package/src/styles/tokens/generated/native.generated.ts +266 -0
  25. package/src/styles/tokens/index.ts +2 -0
  26. package/src/styles/tokens/normalize-native.ts +88 -0
  27. package/src/styles/tokens/source/foundations.tokens.json +2 -1
  28. package/src/styles/tokens/source/native.tokens.json +204 -0
  29. package/src/styles/tokens/style-dictionary.native.mjs +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -133,13 +133,15 @@
133
133
  "style-dictionary": "5.5.0",
134
134
  "tailwindcss": "^4.3.3",
135
135
  "typescript": "6.0.3",
136
+ "uniwind": "^1.10.1",
136
137
  "vite": "^8.1.5",
137
138
  "vitest": "^4.1.10"
138
139
  },
139
140
  "peerDependencies": {
140
141
  "react": "^18.2.0 || ^19.0.0",
141
142
  "react-dom": "^18.2.0 || ^19.0.0",
142
- "react-native": ">=0.74"
143
+ "react-native": ">=0.74",
144
+ "uniwind": ">=1.10.0"
143
145
  },
144
146
  "peerDependenciesMeta": {
145
147
  "react-dom": {
@@ -147,6 +149,9 @@
147
149
  },
148
150
  "react-native": {
149
151
  "optional": true
152
+ },
153
+ "uniwind": {
154
+ "optional": true
150
155
  }
151
156
  },
152
157
  "packageManager": "pnpm@11.10.0"
@@ -196,7 +196,11 @@ function UpdateGuardClient({
196
196
  updateServiceWorker,
197
197
  } = useRegisterSW({ serviceWorkerPath });
198
198
 
199
- const isOnline = useSyncExternalStore(subscribeOnline, getOnlineSnapshot, getOnlineServerSnapshot);
199
+ const isOnline = useSyncExternalStore(
200
+ subscribeOnline,
201
+ getOnlineSnapshot,
202
+ getOnlineServerSnapshot,
203
+ );
200
204
 
201
205
  const closePrompt = () => {
202
206
  setOfflineReady(false);
@@ -1,13 +1,13 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { expect, userEvent, within } from 'storybook/test';
3
- import { fn } from 'storybook/test';
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import { fn } from "storybook/test";
4
4
 
5
- import { ActiveFiltersBar } from './active-filters-bar';
5
+ import { ActiveFiltersBar } from "./active-filters-bar";
6
6
 
7
7
  const meta = {
8
- title: 'Filters/ActiveFiltersBar',
8
+ title: "Filters/ActiveFiltersBar",
9
9
  component: ActiveFiltersBar,
10
- tags: ['autodocs'],
10
+ tags: ["autodocs"],
11
11
  decorators: [
12
12
  (Story) => (
13
13
  <div className="w-[600px]">
@@ -23,36 +23,36 @@ type Story = StoryObj<typeof meta>;
23
23
  export const Default: Story = {
24
24
  args: {
25
25
  filters: [
26
- { id: '1', label: 'Status: Active', onRemove: fn() },
27
- { id: '2', label: 'Created after Jan 1', onRemove: fn() },
26
+ { id: "1", label: "Status: Active", onRemove: fn() },
27
+ { id: "2", label: "Created after Jan 1", onRemove: fn() },
28
28
  ],
29
29
  },
30
30
  play: async ({ canvasElement }) => {
31
31
  const canvas = within(canvasElement);
32
- const list = canvas.getByRole('list');
32
+ const list = canvas.getByRole("list");
33
33
  await expect(list).toBeInTheDocument();
34
- const items = within(list).getAllByRole('listitem');
34
+ const items = within(list).getAllByRole("listitem");
35
35
  expect(items.length).toBe(2);
36
- await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
37
- await expect(canvas.getByText('Created after Jan 1')).toBeInTheDocument();
38
- await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
36
+ await expect(canvas.getByText("Status: Active")).toBeInTheDocument();
37
+ await expect(canvas.getByText("Created after Jan 1")).toBeInTheDocument();
38
+ await expect(canvas.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
39
39
  },
40
40
  };
41
41
 
42
42
  export const WithClearAll: Story = {
43
43
  args: {
44
44
  filters: [
45
- { id: '1', label: 'Status: Active', onRemove: fn() },
46
- { id: '2', label: 'Role: Engineer', onRemove: fn() },
45
+ { id: "1", label: "Status: Active", onRemove: fn() },
46
+ { id: "2", label: "Role: Engineer", onRemove: fn() },
47
47
  ],
48
48
  onClearAll: fn(),
49
49
  },
50
50
  play: async ({ canvasElement, args }) => {
51
51
  const canvas = within(canvasElement);
52
- const list = canvas.getByRole('list');
53
- const items = within(list).getAllByRole('listitem');
52
+ const list = canvas.getByRole("list");
53
+ const items = within(list).getAllByRole("listitem");
54
54
  expect(items.length).toBe(2);
55
- const clearButton = canvas.getByRole('button', { name: 'Clear all' });
55
+ const clearButton = canvas.getByRole("button", { name: "Clear all" });
56
56
  await expect(clearButton).toBeInTheDocument();
57
57
  await userEvent.click(clearButton);
58
58
  await expect(args.onClearAll).toHaveBeenCalledTimes(1);
@@ -61,36 +61,34 @@ export const WithClearAll: Story = {
61
61
 
62
62
  export const WithSortOnly: Story = {
63
63
  args: {
64
- filters: [
65
- { id: '1', label: 'Date', onRemove: fn(), variant: 'sort', direction: 'desc' },
66
- ],
64
+ filters: [{ id: "1", label: "Date", onRemove: fn(), variant: "sort", direction: "desc" }],
67
65
  },
68
66
  play: async ({ canvasElement }) => {
69
67
  const canvas = within(canvasElement);
70
- const list = canvas.getByRole('list');
71
- const items = within(list).getAllByRole('listitem');
68
+ const list = canvas.getByRole("list");
69
+ const items = within(list).getAllByRole("listitem");
72
70
  expect(items.length).toBe(1);
73
- await expect(canvas.getByText('Date')).toBeInTheDocument();
74
- await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
71
+ await expect(canvas.getByText("Date")).toBeInTheDocument();
72
+ await expect(canvas.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
75
73
  },
76
74
  };
77
75
 
78
76
  export const WithSortAndFilter: Story = {
79
77
  args: {
80
78
  filters: [
81
- { id: 'filter-1', label: 'Status: Active', onRemove: fn(), variant: 'filter' },
82
- { id: 'sort-1', label: 'Date', onRemove: fn(), variant: 'sort', direction: 'desc' },
79
+ { id: "filter-1", label: "Status: Active", onRemove: fn(), variant: "filter" },
80
+ { id: "sort-1", label: "Date", onRemove: fn(), variant: "sort", direction: "desc" },
83
81
  ],
84
82
  onClearAll: fn(),
85
83
  },
86
84
  play: async ({ canvasElement }) => {
87
85
  const canvas = within(canvasElement);
88
- const list = canvas.getByRole('list');
89
- const items = within(list).getAllByRole('listitem');
86
+ const list = canvas.getByRole("list");
87
+ const items = within(list).getAllByRole("listitem");
90
88
  expect(items.length).toBe(2);
91
- await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
92
- await expect(canvas.getByText('Date')).toBeInTheDocument();
93
- await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
89
+ await expect(canvas.getByText("Status: Active")).toBeInTheDocument();
90
+ await expect(canvas.getByText("Date")).toBeInTheDocument();
91
+ await expect(canvas.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
94
92
  },
95
93
  };
96
94
 
@@ -98,17 +96,17 @@ export const Empty: Story = {
98
96
  args: { filters: [] },
99
97
  play: async ({ canvasElement }) => {
100
98
  const canvas = within(canvasElement);
101
- expect(canvas.queryByRole('list')).toBeNull();
99
+ expect(canvas.queryByRole("list")).toBeNull();
102
100
  },
103
101
  };
104
102
 
105
103
  export const WithClassName: Story = {
106
104
  args: {
107
- filters: [{ id: '1', label: 'Status: Active', onRemove: fn() }],
108
- className: 'mt-4',
105
+ filters: [{ id: "1", label: "Status: Active", onRemove: fn() }],
106
+ className: "mt-4",
109
107
  },
110
108
  play: async ({ canvasElement }) => {
111
- const container = canvasElement.querySelector('.mt-4');
109
+ const container = canvasElement.querySelector(".mt-4");
112
110
  await expect(container).not.toBeNull();
113
111
  },
114
112
  };
@@ -1,22 +1,22 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { expect, within } from 'storybook/test';
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
3
 
4
- import { FilterSelect } from './filter-select';
4
+ import { FilterSelect } from "./filter-select";
5
5
 
6
6
  const meta = {
7
- title: 'Filters/FilterSelect',
7
+ title: "Filters/FilterSelect",
8
8
  component: FilterSelect,
9
- tags: ['autodocs'],
9
+ tags: ["autodocs"],
10
10
  args: {
11
11
  options: [
12
- { value: 'active', label: 'Active' },
13
- { value: 'interview', label: 'Interview' },
14
- { value: 'offer', label: 'Offer' },
15
- { value: 'rejected', label: 'Rejected' },
16
- { value: 'archived', label: 'Archived' },
12
+ { value: "active", label: "Active" },
13
+ { value: "interview", label: "Interview" },
14
+ { value: "offer", label: "Offer" },
15
+ { value: "rejected", label: "Rejected" },
16
+ { value: "archived", label: "Archived" },
17
17
  ],
18
- placeholder: 'All statuses',
19
- label: 'Status',
18
+ placeholder: "All statuses",
19
+ label: "Status",
20
20
  onChange: () => {},
21
21
  },
22
22
  } satisfies Meta<typeof FilterSelect>;
@@ -25,47 +25,47 @@ export default meta;
25
25
  type Story = StoryObj<typeof meta>;
26
26
 
27
27
  export const Default: Story = {
28
- args: { value: '' },
28
+ args: { value: "" },
29
29
  play: async ({ canvasElement }) => {
30
30
  const canvas = within(canvasElement);
31
- const trigger = canvas.getByRole('combobox', { name: 'Status' });
31
+ const trigger = canvas.getByRole("combobox", { name: "Status" });
32
32
  await expect(trigger).toBeInTheDocument();
33
- await expect(trigger).toHaveTextContent('All statuses');
33
+ await expect(trigger).toHaveTextContent("All statuses");
34
34
  },
35
35
  };
36
36
 
37
37
  export const WithValue: Story = {
38
- args: { value: 'active' },
38
+ args: { value: "active" },
39
39
  play: async ({ canvasElement }) => {
40
40
  const canvas = within(canvasElement);
41
- const trigger = canvas.getByRole('combobox', { name: 'Status' });
42
- await expect(trigger).toHaveTextContent('Active');
41
+ const trigger = canvas.getByRole("combobox", { name: "Status" });
42
+ await expect(trigger).toHaveTextContent("Active");
43
43
  },
44
44
  };
45
45
 
46
46
  export const Loading: Story = {
47
- args: { value: '', isLoading: true, options: [] },
47
+ args: { value: "", isLoading: true, options: [] },
48
48
  play: async ({ canvasElement }) => {
49
49
  const canvas = within(canvasElement);
50
- await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
51
- await expect(canvas.getByText('Status')).toBeInTheDocument();
50
+ await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
51
+ await expect(canvas.getByText("Status")).toBeInTheDocument();
52
52
  },
53
53
  };
54
54
 
55
55
  export const Empty: Story = {
56
- args: { value: '', options: [], emptyLabel: 'No statuses found' },
56
+ args: { value: "", options: [], emptyLabel: "No statuses found" },
57
57
  play: async ({ canvasElement }) => {
58
58
  const canvas = within(canvasElement);
59
- await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
60
- await expect(canvas.getByText('Status')).toBeInTheDocument();
59
+ await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
60
+ await expect(canvas.getByText("Status")).toBeInTheDocument();
61
61
  },
62
62
  };
63
63
 
64
64
  export const Disabled: Story = {
65
- args: { value: 'active', disabled: true },
65
+ args: { value: "active", disabled: true },
66
66
  play: async ({ canvasElement }) => {
67
67
  const canvas = within(canvasElement);
68
- const trigger = canvas.getByRole('combobox', { name: 'Status' });
68
+ const trigger = canvas.getByRole("combobox", { name: "Status" });
69
69
  await expect(trigger).toBeDisabled();
70
70
  },
71
71
  };
@@ -47,12 +47,7 @@ export function FilterSelect({
47
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
- <SelectTrigger
51
- id={selectId}
52
- disabled={disabled}
53
- aria-label={label}
54
- className="w-full"
55
- >
50
+ <SelectTrigger id={selectId} disabled={disabled} aria-label={label} className="w-full">
56
51
  <SelectValue placeholder={placeholder} />
57
52
  </SelectTrigger>
58
53
  <SelectContent className="bg-background max-h-[250px] min-w-36 overflow-y-auto">
@@ -1,13 +1,13 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { expect, within } from 'storybook/test';
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
3
 
4
- import { Input } from '../forms/input';
5
- import { Filters } from './filters';
4
+ import { Input } from "../forms/input";
5
+ import { Filters } from "./filters";
6
6
 
7
7
  const meta = {
8
- title: 'Filters/Compound',
8
+ title: "Filters/Compound",
9
9
  component: Filters,
10
- tags: ['autodocs'],
10
+ tags: ["autodocs"],
11
11
  args: {
12
12
  children: null,
13
13
  },
@@ -17,9 +17,9 @@ export default meta;
17
17
  type Story = StoryObj<typeof meta>;
18
18
 
19
19
  const statusOptions = [
20
- { value: 'active', label: 'Active' },
21
- { value: 'interview', label: 'Interview' },
22
- { value: 'offer', label: 'Offer' },
20
+ { value: "active", label: "Active" },
21
+ { value: "interview", label: "Interview" },
22
+ { value: "offer", label: "Offer" },
23
23
  ];
24
24
 
25
25
  export const Default: Story = {
@@ -38,15 +38,15 @@ export const Default: Story = {
38
38
  />
39
39
  <Filters.Select
40
40
  value=""
41
- options={[{ value: 'engineer', label: 'Engineer' }]}
41
+ options={[{ value: "engineer", label: "Engineer" }]}
42
42
  onChange={() => {}}
43
43
  placeholder="All roles"
44
44
  label="Role"
45
45
  />
46
46
  <Filters.Active
47
47
  filters={[
48
- { id: 'status', label: 'Status: Active', onRemove: () => {} },
49
- { id: 'sort', label: 'Date', onRemove: () => {}, variant: 'sort', direction: 'desc' },
48
+ { id: "status", label: "Status: Active", onRemove: () => {} },
49
+ { id: "sort", label: "Date", onRemove: () => {}, variant: "sort", direction: "desc" },
50
50
  ]}
51
51
  onClearAll={() => {}}
52
52
  />
@@ -56,17 +56,17 @@ export const Default: Story = {
56
56
  play: async ({ canvasElement }) => {
57
57
  const canvas = within(canvasElement);
58
58
 
59
- await expect(canvas.getByText('Search')).toBeInTheDocument();
60
- await expect(canvas.getByRole('textbox', { name: 'Search applications' })).toBeInTheDocument();
59
+ await expect(canvas.getByText("Search")).toBeInTheDocument();
60
+ await expect(canvas.getByRole("textbox", { name: "Search applications" })).toBeInTheDocument();
61
61
 
62
- await expect(canvas.getByText('Status')).toBeInTheDocument();
63
- await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
62
+ await expect(canvas.getByText("Status")).toBeInTheDocument();
63
+ await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
64
64
 
65
- await expect(canvas.getByText('Role')).toBeInTheDocument();
66
- await expect(canvas.getByRole('combobox', { name: 'Role' })).toBeInTheDocument();
65
+ await expect(canvas.getByText("Role")).toBeInTheDocument();
66
+ await expect(canvas.getByRole("combobox", { name: "Role" })).toBeInTheDocument();
67
67
 
68
- await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
69
- await expect(canvas.getByText('Date')).toBeInTheDocument();
70
- await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
68
+ await expect(canvas.getByText("Status: Active")).toBeInTheDocument();
69
+ await expect(canvas.getByText("Date")).toBeInTheDocument();
70
+ await expect(canvas.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
71
71
  },
72
72
  };
@@ -5,13 +5,7 @@ import { ActiveFiltersBar, type ActiveFiltersBarProps } from "./active-filters-b
5
5
  import { FilterSelect, type FilterSelectProps } from "./filter-select";
6
6
  import { Label } from "../primitives/label";
7
7
 
8
- export function FiltersSearch({
9
- label,
10
- children,
11
- }: {
12
- label: string;
13
- children: ReactNode;
14
- }) {
8
+ export function FiltersSearch({ label, children }: { label: string; children: ReactNode }) {
15
9
  const id = `filters-search-${label.toLowerCase().replace(/\s+/g, "-")}`;
16
10
  return (
17
11
  <div className="flex-1 space-y-2">
@@ -1,13 +1,13 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
 
3
- import { Input } from '../forms/input';
4
- import { FilterSelect } from './filter-select';
5
- import { SearchFilterBar } from './search-filter-bar';
3
+ import { Input } from "../forms/input";
4
+ import { FilterSelect } from "./filter-select";
5
+ import { SearchFilterBar } from "./search-filter-bar";
6
6
 
7
7
  const meta = {
8
- title: 'Filters/SearchFilterBar',
8
+ title: "Filters/SearchFilterBar",
9
9
  component: SearchFilterBar,
10
- tags: ['autodocs'],
10
+ tags: ["autodocs"],
11
11
  } satisfies Meta<typeof SearchFilterBar>;
12
12
 
13
13
  export default meta;
@@ -20,16 +20,14 @@ export const Legacy: Story = {
20
20
  render: () => (
21
21
  <div className="w-[720px]">
22
22
  <SearchFilterBar
23
- activeFilters={[
24
- { id: 'status', label: 'Status: Active', onRemove: () => {} },
25
- ]}
23
+ activeFilters={[{ id: "status", label: "Status: Active", onRemove: () => {} }]}
26
24
  onClearAll={() => {}}
27
25
  search={<Input placeholder="Search..." aria-label="Search" />}
28
26
  filters={[
29
27
  <FilterSelect
30
28
  key="status"
31
29
  value="active"
32
- options={[{ value: 'active', label: 'Active' }]}
30
+ options={[{ value: "active", label: "Active" }]}
33
31
  onChange={() => {}}
34
32
  placeholder="All"
35
33
  label="Status"
@@ -1,7 +1,7 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode } from "react";
2
2
 
3
- import { cn } from '../../lib/utils';
4
- import { ActiveFiltersBar, type ActiveFilter } from './active-filters-bar';
3
+ import { cn } from "../../lib/utils";
4
+ import { ActiveFiltersBar, type ActiveFilter } from "./active-filters-bar";
5
5
 
6
6
  export interface SearchFilterBarProps {
7
7
  activeFilters: ActiveFilter[];
@@ -22,10 +22,10 @@ export function SearchFilterBar({
22
22
  results,
23
23
  children,
24
24
  className,
25
- filterItemClassName = 'sm:w-48',
25
+ filterItemClassName = "sm:w-48",
26
26
  }: SearchFilterBarProps) {
27
27
  return (
28
- <div className={cn('flex flex-col gap-4', className)}>
28
+ <div className={cn("flex flex-col gap-4", className)}>
29
29
  <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
30
30
  {search ? <div className="flex-1">{search}</div> : null}
31
31
 
@@ -35,9 +35,7 @@ export function SearchFilterBar({
35
35
  </div>
36
36
  ))}
37
37
 
38
- {results ? (
39
- <div className="self-start lg:ml-auto lg:self-auto">{results}</div>
40
- ) : null}
38
+ {results ? <div className="self-start lg:ml-auto lg:self-auto">{results}</div> : null}
41
39
 
42
40
  {children}
43
41
  </div>
@@ -121,7 +121,11 @@ export const WithCustomRouterLink: Story = {
121
121
 
122
122
  <Navigation.Action>
123
123
  <Button size="md" asChild>
124
- <a href="/get-started" data-router-link="stand-in" onClick={(e) => e.preventDefault()}>
124
+ <a
125
+ href="/get-started"
126
+ data-router-link="stand-in"
127
+ onClick={(e) => e.preventDefault()}
128
+ >
125
129
  Get started
126
130
  </a>
127
131
  </Button>
@@ -26,17 +26,26 @@ export interface NavigationRootProps {
26
26
  * shape) so `Navigation.Item`'s active state and `asChild` composition
27
27
  * behave identically in both places.
28
28
  */
29
- function NavigationRoot({ children, ariaLabel = "Primary navigation", className }: NavigationRootProps) {
29
+ function NavigationRoot({
30
+ children,
31
+ ariaLabel = "Primary navigation",
32
+ className,
33
+ }: NavigationRootProps) {
30
34
  const [mobileOpen, setMobileOpen] = React.useState(false);
31
35
 
32
36
  const items = React.Children.toArray(children);
33
- const brand = items.find((child) => React.isValidElement(child) && child.type === NavigationBrand);
37
+ const brand = items.find(
38
+ (child) => React.isValidElement(child) && child.type === NavigationBrand,
39
+ );
34
40
  const rest = items.filter((child) => child !== brand);
35
41
 
36
42
  return (
37
43
  <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">
38
44
  <nav
39
- className={cn("flex min-h-14 w-full max-w-7xl items-center justify-between gap-6", className)}
45
+ className={cn(
46
+ "flex min-h-14 w-full max-w-7xl items-center justify-between gap-6",
47
+ className,
48
+ )}
40
49
  aria-label={ariaLabel}
41
50
  >
42
51
  <div className="flex min-w-0 items-center">{brand}</div>
@@ -108,7 +117,13 @@ export type NavigationItemProps = NavigationItemOwnProps &
108
117
  Omit<ComponentPropsWithoutRef<"a">, keyof NavigationItemOwnProps>;
109
118
 
110
119
  /** A single nav link, wrapped in `<li>`. Auto-closes the mobile Sheet on click when rendered there. */
111
- function NavigationItem({ active = false, asChild = false, className, children, ...props }: NavigationItemProps) {
120
+ function NavigationItem({
121
+ active = false,
122
+ asChild = false,
123
+ className,
124
+ children,
125
+ ...props
126
+ }: NavigationItemProps) {
112
127
  const isMobile = React.useContext(MobileMenuContext);
113
128
  const itemClassName = cn(
114
129
  "inline-flex h-9 max-h-9 shrink-0 items-center px-3 py-2 text-sm font-medium hover:bg-muted hover:text-muted-foreground data-[active]:font-semibold data-[active]:text-foreground",
@@ -49,18 +49,28 @@ const SheetOverlay = React.forwardRef<
49
49
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Backdrop>
50
50
  >(({ className, ...props }, ref) => (
51
51
  <SheetPrimitive.Backdrop
52
- className={cn(
53
- "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 transition-opacity",
54
- className,
55
- )}
52
+ data-slot="sheet-overlay"
53
+ className={cn("fixed inset-0 z-50 bg-black/80", className)}
56
54
  {...props}
57
55
  ref={ref}
58
56
  />
59
57
  ));
60
58
  SheetOverlay.displayName = "SheetOverlay";
61
59
 
62
- const sheetClassName =
63
- "bg-background text-foreground data-open:animate-in data-closed:animate-out fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] min-h-[30dvh] flex-col gap-6 rounded-t-xl border border-b-0 border-l-0 border-r-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:inset-y-0 md:right-0 md:bottom-auto md:left-auto md:h-full md:w-3/4 md:max-w-sm md:min-h-0 md:rounded-t-none md:rounded-l-xl md:border-t-0 md:border-r-0 md:border-b-0 md:border-l md:p-6 md:pb-6";
60
+ const sheetClassName = [
61
+ // Appearance
62
+ "bg-background text-foreground rounded-xl border shadow-lg",
63
+ // Layout
64
+ "flex flex-col gap-6 p-4",
65
+ // Position: floats off every edge, anchored to the bottom on mobile
66
+ "fixed z-50 inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]",
67
+ // Size: mobile
68
+ "max-h-[calc(100dvh-2rem)] min-h-[30dvh]",
69
+ // Position: anchored to the right edge on desktop
70
+ "md:inset-y-2 md:right-2 md:left-auto md:bottom-auto",
71
+ // Size: desktop
72
+ "md:w-3/4 md:max-w-sm md:min-h-0",
73
+ ];
64
74
 
65
75
  interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Popup> {}
66
76
 
@@ -70,7 +80,12 @@ const SheetContent = React.forwardRef<
70
80
  >(({ className, children, ...props }, ref) => (
71
81
  <SheetPortal>
72
82
  <SheetOverlay />
73
- <SheetPrimitive.Popup ref={ref} className={cn(sheetClassName, className)} {...props}>
83
+ <SheetPrimitive.Popup
84
+ ref={ref}
85
+ data-slot="sheet-content"
86
+ className={cn(sheetClassName, className)}
87
+ {...props}
88
+ >
74
89
  <SheetPrimitive.Close className="text-muted-foreground hover:bg-muted hover:text-muted-foreground focus-visible:outline-ring absolute top-3 right-3 inline-flex size-9 items-center justify-center rounded-md border border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none md:top-4 md:right-4">
75
90
  <X className="size-4.5" />
76
91
  <span className="sr-only">Close</span>
@@ -53,9 +53,7 @@ export function useDerivedFilterState<T extends Record<string, FilterConfig>>({
53
53
  .filter(([, v]) => v !== "")
54
54
  .map(([key, v]) => ({
55
55
  id: key,
56
- label: fields[key]?.label
57
- ? fields[key].label!(v)
58
- : `${key}: ${v}`,
56
+ label: fields[key]?.label ? fields[key].label!(v) : `${key}: ${v}`,
59
57
  onRemove: () => setValue(key, ""),
60
58
  }));
61
59
  }, [values, fields, setValue]);
@@ -18,8 +18,7 @@ export function useFilterState<T extends Record<string, unknown>>(
18
18
  const setFilters = useCallback(
19
19
  (newFilters: T | ((prev: T) => T)) => {
20
20
  setFiltersState((prev) => {
21
- const updatedFilters =
22
- typeof newFilters === "function" ? newFilters(prev) : newFilters;
21
+ const updatedFilters = typeof newFilters === "function" ? newFilters(prev) : newFilters;
23
22
 
24
23
  if (debounceMs && debounceMs > 0) {
25
24
  if (debounceTimerRef.current) {
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+ import { Text, View, type TextProps, type ViewProps } from "react-native";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ function Card({ className, ...props }: ViewProps) {
7
+ return (
8
+ <View className={cn("bg-card gap-4 rounded border border-border p-4", className)} {...props} />
9
+ );
10
+ }
11
+
12
+ function CardHeader({ className, ...props }: ViewProps) {
13
+ return <View className={cn("gap-1", className)} {...props} />;
14
+ }
15
+
16
+ function CardTitle({ className, ...props }: TextProps) {
17
+ return <Text className={cn("text-card-foreground text-xl", className)} {...props} />;
18
+ }
19
+
20
+ function CardDescription({ className, ...props }: TextProps) {
21
+ return <Text className={cn("text-muted-foreground text-xs", className)} {...props} />;
22
+ }
23
+
24
+ function CardAction({ className, ...props }: ViewProps) {
25
+ return <View className={cn("self-start", className)} {...props} />;
26
+ }
27
+
28
+ function CardContent({ className, ...props }: ViewProps) {
29
+ return <View className={className} {...props} />;
30
+ }
31
+
32
+ function CardFooter({ className, ...props }: ViewProps) {
33
+ return <View className={cn("flex-row items-center", className)} {...props} />;
34
+ }
35
+
36
+ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };