@ponti-studios/ui 1.6.0 → 1.7.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 (77) hide show
  1. package/README.md +7 -11
  2. package/package.json +6 -31
  3. package/src/components/feedback/alert.tsx +8 -4
  4. package/src/components/feedback/index.ts +0 -4
  5. package/src/components/feedback/progress.stories.tsx +15 -0
  6. package/src/components/feedback/progress.tsx +14 -3
  7. package/src/components/feedback/update-guard.stories.tsx +4 -8
  8. package/src/components/feedback/update-guard.tsx +41 -272
  9. package/src/components/filters/filter-select.tsx +3 -5
  10. package/src/components/forms/date-picker.stories.tsx +158 -18
  11. package/src/components/forms/date-picker.tsx +286 -64
  12. package/src/components/forms/date-utils.ts +7 -2
  13. package/src/components/forms/drop-zone.tsx +11 -12
  14. package/src/components/forms/index.ts +2 -6
  15. package/src/components/forms/passkey-management.stories.tsx +8 -4
  16. package/src/components/forms/passkey-management.tsx +78 -42
  17. package/src/components/forms/radio-group.stories.tsx +3 -5
  18. package/src/components/forms/radio-group.tsx +2 -2
  19. package/src/components/forms/select.stories.tsx +16 -13
  20. package/src/components/forms/textarea.stories.tsx +1 -1
  21. package/src/components/layout/index.ts +2 -0
  22. package/src/components/layout/particle-background.stories.tsx +38 -0
  23. package/src/components/layout/particle-background.tsx +378 -0
  24. package/src/components/layout/scroll-area.stories.tsx +12 -4
  25. package/src/components/layout/scroll-area.tsx +41 -4
  26. package/src/components/navigation/tabs.stories.tsx +0 -21
  27. package/src/components/overlays/dialog.stories.tsx +38 -1
  28. package/src/components/overlays/dialog.tsx +53 -12
  29. package/src/components/overlays/index.ts +0 -11
  30. package/src/components/primitives/avatar-placeholder.svg +13 -0
  31. package/src/components/primitives/avatar.stories.tsx +2 -1
  32. package/src/components/primitives/badge.stories.tsx +13 -3
  33. package/src/components/primitives/badge.tsx +1 -1
  34. package/src/components/primitives/button.tsx +1 -1
  35. package/src/components/primitives/card.tsx +6 -2
  36. package/src/components/primitives/index.ts +0 -3
  37. package/src/index.ts +1 -3
  38. package/src/lib/index.ts +2 -3
  39. package/src/styles/animations.css +62 -0
  40. package/src/styles/index.css +27 -0
  41. package/src/styles/tokens/generated/dark.colors.generated.ts +15 -12
  42. package/src/styles/tokens/generated/dark.css +15 -12
  43. package/src/styles/tokens/generated/light.colors.generated.ts +14 -11
  44. package/src/styles/tokens/generated/light.css +14 -11
  45. package/src/styles/tokens/index.ts +0 -3
  46. package/src/styles/tokens/source/dark.tokens.json +24 -12
  47. package/src/styles/tokens/source/light.tokens.json +23 -11
  48. package/src/styles/tokens/typography.stories.tsx +157 -0
  49. package/src/components/feedback/file-upload-status.stories.tsx +0 -57
  50. package/src/components/feedback/file-upload-status.tsx +0 -160
  51. package/src/components/feedback/progress-bar.stories.tsx +0 -39
  52. package/src/components/feedback/progress-bar.tsx +0 -22
  53. package/src/components/forms/calendar.stories.tsx +0 -30
  54. package/src/components/forms/calendar.tsx +0 -75
  55. package/src/components/forms/date-field.stories.tsx +0 -202
  56. package/src/components/forms/date-field.tsx +0 -179
  57. package/src/components/overlays/alert-dialog.stories.tsx +0 -45
  58. package/src/components/overlays/alert-dialog.tsx +0 -116
  59. package/src/components/primitives/label.stories.tsx +0 -29
  60. package/src/components/primitives/label.tsx +0 -20
  61. package/src/components/primitives/status-badge.stories.tsx +0 -52
  62. package/src/components/primitives/status-badge.tsx +0 -33
  63. package/src/lib/create-memory-storage.ts +0 -24
  64. package/src/native/card.tsx +0 -36
  65. package/src/native/icon-button.tsx +0 -41
  66. package/src/native/index.ts +0 -38
  67. package/src/native/list-row.tsx +0 -67
  68. package/src/native/make-styles.ts +0 -32
  69. package/src/native/shadows.ts +0 -38
  70. package/src/native/text-field.tsx +0 -34
  71. package/src/native/uniwind.d.ts +0 -1
  72. package/src/native/use-theme-colors.ts +0 -19
  73. package/src/styles/tokens/foundations.stories.tsx +0 -109
  74. package/src/styles/tokens/generated/native.generated.ts +0 -266
  75. package/src/styles/tokens/normalize-native.ts +0 -88
  76. package/src/styles/tokens/source/native.tokens.json +0 -204
  77. package/src/styles/tokens/style-dictionary.native.mjs +0 -26
@@ -17,14 +17,13 @@ const initialPasskeys: PasskeyRecord[] = [
17
17
  { id: "2", name: "iPhone", createdAt: "2025-03-02T00:00:00.000Z" },
18
18
  ];
19
19
 
20
- function PasskeyManagementPreview({ withError = false }: { withError?: boolean }) {
20
+ function PasskeyManagementPreview({ withDeleteError = false }: { withDeleteError?: boolean }) {
21
21
  const [passkeys, setPasskeys] = useState(initialPasskeys);
22
22
 
23
23
  return (
24
24
  <div className="w-96">
25
25
  <PasskeyManagement
26
26
  passkeys={passkeys}
27
- error={withError ? "Could not delete passkey. Please try again." : null}
28
27
  onAdd={async () => {
29
28
  setPasskeys((prev) => [
30
29
  ...prev,
@@ -37,6 +36,7 @@ function PasskeyManagementPreview({ withError = false }: { withError?: boolean }
37
36
  return true;
38
37
  }}
39
38
  onDelete={async (id) => {
39
+ if (withDeleteError && id === "2") return false;
40
40
  setPasskeys((prev) => prev.filter((pk) => pk.id !== id));
41
41
  return true;
42
42
  }}
@@ -68,14 +68,18 @@ export const Loading: Story = {
68
68
  onAdd: async () => true,
69
69
  onDelete: async () => true,
70
70
  },
71
+ render: (args) => (
72
+ <div className="w-[calc(100vw-2rem)] max-w-md">
73
+ <PasskeyManagement {...args} />
74
+ </div>
75
+ ),
71
76
  };
72
77
 
73
78
  export const WithError: Story = {
74
79
  args: {
75
80
  passkeys: initialPasskeys,
76
- error: "Could not delete passkey. Please try again.",
77
81
  onAdd: async () => true,
78
82
  onDelete: async () => true,
79
83
  },
80
- render: () => <PasskeyManagementPreview withError />,
84
+ render: () => <PasskeyManagementPreview withDeleteError />,
81
85
  };
@@ -1,7 +1,9 @@
1
- import { KeyRound, Plus, Trash2 } from "lucide-react";
1
+ import { KeyRound, Plus, Trash2, X } from "lucide-react";
2
2
  import { useCallback, useState } from "react";
3
3
 
4
+ import { Spinner } from "../feedback/spinner";
4
5
  import { Button } from "../primitives/button";
6
+ import { Card, CardHeader } from "../primitives/card";
5
7
 
6
8
  export interface PasskeyRecord {
7
9
  id: string;
@@ -17,6 +19,11 @@ export interface PasskeyManagementProps {
17
19
  onDelete: (id: string) => Promise<boolean>;
18
20
  }
19
21
 
22
+ type PasskeyActionError = {
23
+ message: string;
24
+ passkeyId?: string;
25
+ };
26
+
20
27
  /** List, add, and remove passkeys. The host app owns the WebAuthn ceremony via `onAdd`/`onDelete`. */
21
28
  export function PasskeyManagement({
22
29
  passkeys: passkeysProp,
@@ -27,10 +34,10 @@ export function PasskeyManagement({
27
34
  }: PasskeyManagementProps) {
28
35
  const [adding, setAdding] = useState(false);
29
36
  const [deletingId, setDeletingId] = useState<string | null>(null);
30
- const [actionError, setActionError] = useState<string | null>(null);
37
+ const [actionError, setActionError] = useState<PasskeyActionError | null>(null);
31
38
 
32
39
  const passkeys = passkeysProp ?? [];
33
- const error = externalError ?? actionError;
40
+ const error = externalError ?? (actionError?.passkeyId ? null : actionError?.message);
34
41
 
35
42
  const handleAdd = useCallback(async () => {
36
43
  setAdding(true);
@@ -38,10 +45,10 @@ export function PasskeyManagement({
38
45
  try {
39
46
  const success = await onAdd();
40
47
  if (!success) {
41
- setActionError("Passkey registration was cancelled or failed.");
48
+ setActionError({ message: "Passkey registration was cancelled or failed." });
42
49
  }
43
50
  } catch {
44
- setActionError("An error occurred during passkey registration.");
51
+ setActionError({ message: "An error occurred during passkey registration." });
45
52
  } finally {
46
53
  setAdding(false);
47
54
  }
@@ -57,7 +64,10 @@ export function PasskeyManagement({
57
64
  throw new Error("Failed to delete passkey");
58
65
  }
59
66
  } catch {
60
- setActionError("Could not delete passkey. Please try again.");
67
+ setActionError({
68
+ message: "Could not delete passkey. Please try again.",
69
+ passkeyId: id,
70
+ });
61
71
  } finally {
62
72
  setDeletingId(null);
63
73
  }
@@ -66,8 +76,8 @@ export function PasskeyManagement({
66
76
  );
67
77
 
68
78
  return (
69
- <section aria-labelledby="passkey-heading" className="space-y-4">
70
- <div className="flex items-center justify-between">
79
+ <Card aria-labelledby="passkey-heading" className="gap-0 overflow-hidden w-full">
80
+ <CardHeader className="flex-row items-center border-b p-4">
71
81
  <h2 id="passkey-heading" className="text-foreground text-sm font-medium">
72
82
  Passkeys
73
83
  </h2>
@@ -75,58 +85,84 @@ export function PasskeyManagement({
75
85
  type="button"
76
86
  size="sm"
77
87
  onClick={() => void handleAdd()}
78
- disabled={adding}
88
+ isLoading={adding}
89
+ loadingLabel="Adding passkey"
79
90
  aria-label="Add a passkey"
91
+ className="ml-auto"
80
92
  >
81
93
  <Plus className="size-4" aria-hidden />
82
- {adding ? "Adding..." : "Add passkey"}
83
94
  </Button>
84
- </div>
95
+ </CardHeader>
85
96
 
86
97
  {error ? (
87
- <p role="alert" className="text-destructive-text text-sm">
98
+ <p role="alert" className="text-destructive-text border-b px-4 py-3 text-sm">
88
99
  {error}
89
100
  </p>
90
101
  ) : null}
91
102
 
92
103
  {isLoading ? (
93
- <p className="text-muted-foreground text-sm">Loading passkeys...</p>
104
+ <Spinner presentation="centered" size="sm" label="Loading passkeys" />
94
105
  ) : passkeys.length === 0 ? (
95
- <div className="border-border text-muted-foreground flex items-center gap-3 border border-dashed p-4 text-sm">
106
+ <div className="text-muted-foreground flex items-center gap-3 p-4 text-sm">
96
107
  <KeyRound className="size-4 shrink-0" aria-hidden />
97
108
  <span>No passkeys registered. Add one to sign in faster.</span>
98
109
  </div>
99
110
  ) : (
100
- <ul className="space-y-2" aria-label="Registered passkeys">
101
- {passkeys.map((pk) => (
102
- <li
103
- key={pk.id}
104
- className="border-border rounded flex items-center justify-between border px-4 py-3 text-sm"
105
- >
106
- <div className="flex items-center gap-3">
107
- <KeyRound className="text-muted-foreground size-4 shrink-0" aria-hidden />
108
- <div>
109
- <span className="font-medium">{pk.name ?? "Passkey"}</span>
110
- {pk.createdAt ? (
111
- <span className="text-muted-foreground ml-2 text-xs">
112
- Added {new Date(pk.createdAt).toLocaleDateString()}
113
- </span>
114
- ) : null}
111
+ <ul className="divide-border divide-y" aria-label="Registered passkeys">
112
+ {passkeys.map((pk) => {
113
+ const rowError = actionError?.passkeyId === pk.id ? actionError.message : null;
114
+
115
+ return (
116
+ <li key={pk.id} className="flex flex-col gap-2 px-4 py-3 text-sm">
117
+ <div className="flex items-center justify-between gap-4">
118
+ <div className="flex min-w-0 items-center gap-3">
119
+ <KeyRound className="text-muted-foreground size-4 shrink-0" aria-hidden />
120
+ <div className="min-w-0">
121
+ <span className="font-medium">{pk.name ?? "Passkey"}</span>
122
+ {pk.createdAt ? (
123
+ <span className="text-muted-foreground ml-2 text-xs">
124
+ Added {new Date(pk.createdAt).toLocaleDateString()}
125
+ </span>
126
+ ) : null}
127
+ </div>
128
+ </div>
129
+ <button
130
+ type="button"
131
+ onClick={() => void handleDelete(pk.id)}
132
+ disabled={deletingId === pk.id}
133
+ aria-busy={deletingId === pk.id}
134
+ aria-label={`Remove passkey ${pk.name ?? pk.id}`}
135
+ className="hover:text-destructive-text text-muted-foreground focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
136
+ >
137
+ {deletingId === pk.id ? (
138
+ <Spinner size="sm" aria-hidden />
139
+ ) : (
140
+ <Trash2 className="size-4" aria-hidden />
141
+ )}
142
+ </button>
115
143
  </div>
116
- </div>
117
- <button
118
- type="button"
119
- onClick={() => void handleDelete(pk.id)}
120
- disabled={deletingId === pk.id}
121
- aria-label={`Remove passkey ${pk.name ?? pk.id}`}
122
- className="hover:text-destructive-text text-muted-foreground focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
123
- >
124
- <Trash2 className="size-4" aria-hidden />
125
- </button>
126
- </li>
127
- ))}
144
+ {rowError ? (
145
+ <div
146
+ role="alert"
147
+ className="bg-destructive/5 text-destructive-text flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm"
148
+ >
149
+ <span>{rowError}</span>
150
+ <button
151
+ type="button"
152
+ onClick={() => setActionError(null)}
153
+ aria-label="Dismiss passkey error"
154
+ title="Dismiss error"
155
+ className="hover:text-destructive focus-visible:ring-ring -m-1 shrink-0 rounded-sm p-1 focus-visible:ring-2 focus-visible:outline-none"
156
+ >
157
+ <X className="size-4" aria-hidden />
158
+ </button>
159
+ </div>
160
+ ) : null}
161
+ </li>
162
+ );
163
+ })}
128
164
  </ul>
129
165
  )}
130
- </section>
166
+ </Card>
131
167
  );
132
168
  }
@@ -1,7 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import * as React from "react";
3
2
 
4
- import { Label } from "../primitives/label";
5
3
  import { RadioGroup, RadioGroupItem } from "./radio-group";
6
4
 
7
5
  const meta: Meta<typeof RadioGroup> = {
@@ -25,9 +23,9 @@ export const Default: Story = {
25
23
  {options.map(({ value, label }) => (
26
24
  <div key={value} className="flex items-center gap-3">
27
25
  <RadioGroupItem value={value} id={`opt-${value}`} />
28
- <Label htmlFor={`opt-${value}`} className="cursor-pointer">
26
+ <label htmlFor={`opt-${value}`} className="cursor-pointer">
29
27
  {label}
30
- </Label>
28
+ </label>
31
29
  </div>
32
30
  ))}
33
31
  </RadioGroup>
@@ -40,7 +38,7 @@ export const Disabled: Story = {
40
38
  {options.map(({ value, label }) => (
41
39
  <div key={value} className="flex items-center gap-3">
42
40
  <RadioGroupItem value={value} id={`dis-${value}`} />
43
- <Label htmlFor={`dis-${value}`}>{label}</Label>
41
+ <label htmlFor={`dis-${value}`}>{label}</label>
44
42
  </div>
45
43
  ))}
46
44
  </RadioGroup>
@@ -23,7 +23,7 @@ const RadioGroupItem = React.forwardRef<
23
23
  <RadioPrimitive.Root
24
24
  ref={ref}
25
25
  className={cn(
26
- "border-primary bg-background flex aspect-square size-6 items-center justify-center rounded-full border",
26
+ "border-ring bg-background flex aspect-square size-6 items-center justify-center rounded-full border",
27
27
  "focus-visible:border-ring focus-visible:bg-muted/25 focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
28
28
  "disabled:cursor-not-allowed disabled:opacity-50",
29
29
  className,
@@ -34,7 +34,7 @@ const RadioGroupItem = React.forwardRef<
34
34
  keepMounted
35
35
  className="data-unchecked:opacity-0 data-checked:opacity-100 data-starting-style:animate-fade-in data-ending-style:animate-fade-out"
36
36
  >
37
- <Circle className="fill-primary size-5" />
37
+ <Circle className="fill-ring size-5" />
38
38
  </RadioPrimitive.Indicator>
39
39
  </RadioPrimitive.Root>
40
40
  );
@@ -51,9 +51,9 @@ export const SelectedValue: Story = {
51
51
  </Select>
52
52
  ),
53
53
  play: async ({ canvasElement }) => {
54
- await expect(within(canvasElement).getByRole("combobox", { name: "Selected fruit" })).toHaveTextContent(
55
- "Banana",
56
- );
54
+ await expect(
55
+ within(canvasElement).getByRole("combobox", { name: "Selected fruit" }),
56
+ ).toHaveTextContent("Banana");
57
57
  },
58
58
  };
59
59
 
@@ -76,18 +76,15 @@ export const ExplicitItems: Story = {
76
76
  </Select>
77
77
  ),
78
78
  play: async ({ canvasElement }) => {
79
- await expect(within(canvasElement).getByRole("combobox", { name: "Country" })).toHaveTextContent(
80
- "United States",
81
- );
79
+ await expect(
80
+ within(canvasElement).getByRole("combobox", { name: "Country" }),
81
+ ).toHaveTextContent("United States");
82
82
  },
83
83
  };
84
84
 
85
85
  export const NullItem: Story = {
86
86
  render: () => (
87
- <Select
88
- defaultValue={null}
89
- items={[{ value: null, label: "All options" }]}
90
- >
87
+ <Select defaultValue={null} items={[{ value: null, label: "All options" }]}>
91
88
  <SelectTrigger aria-label="Filter">
92
89
  <SelectValue placeholder="Choose a filter" />
93
90
  </SelectTrigger>
@@ -142,9 +139,13 @@ export const ControlledNullChange: Story = {
142
139
  const trigger = canvas.getByRole("combobox", { name: "Controlled filter" });
143
140
 
144
141
  await userEvent.click(trigger);
145
- await userEvent.click(await within(document.body).findByRole("option", { name: "All options" }));
142
+ await userEvent.click(
143
+ await within(document.body).findByRole("option", { name: "All options" }),
144
+ );
146
145
  await expect(trigger).toHaveTextContent("All options");
147
- await expect(canvas.getByRole("status", { name: "Last selected value" })).toHaveTextContent("null");
146
+ await expect(canvas.getByRole("status", { name: "Last selected value" })).toHaveTextContent(
147
+ "null",
148
+ );
148
149
  await userEvent.keyboard("{Escape}");
149
150
  },
150
151
  };
@@ -184,7 +185,9 @@ export const Disabled: Story = {
184
185
  </Select>
185
186
  ),
186
187
  play: async ({ canvasElement }) => {
187
- await expect(within(canvasElement).getByRole("combobox", { name: "Select a fruit" })).toBeDisabled();
188
+ await expect(
189
+ within(canvasElement).getByRole("combobox", { name: "Select a fruit" }),
190
+ ).toBeDisabled();
188
191
  },
189
192
  };
190
193
 
@@ -37,7 +37,7 @@ function TextareaField({
37
37
  </p>
38
38
  ) : null}
39
39
  {error ? (
40
- <p id={errorId} className="text-destructive text-sm" role="alert">
40
+ <p id={errorId} className="text-destructive-text text-sm" role="alert">
41
41
  {error}
42
42
  </p>
43
43
  ) : null}
@@ -1,3 +1,5 @@
1
+ export { ParticleBackground } from "./particle-background";
2
+ export type { ParticleBackgroundPalette, ParticleBackgroundProps } from "./particle-background";
1
3
  export { ScrollArea } from "./scroll-area";
2
4
  export type { ScrollAreaProps } from "./scroll-area";
3
5
  export { SectionIntro } from "./section-intro";
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { ParticleBackground } from "./particle-background";
4
+
5
+ const meta = {
6
+ title: "Layout/ParticleBackground",
7
+ component: ParticleBackground,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof ParticleBackground>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ render: (args) => (
16
+ <div className="bg-background relative h-80 w-[640px] overflow-hidden">
17
+ <ParticleBackground {...args} />
18
+ </div>
19
+ ),
20
+ };
21
+
22
+ export const Static: Story = {
23
+ args: { interactive: false },
24
+ render: (args) => (
25
+ <div className="bg-background relative h-80 w-[640px] overflow-hidden">
26
+ <ParticleBackground {...args} />
27
+ </div>
28
+ ),
29
+ };
30
+
31
+ export const Disabled: Story = {
32
+ args: { enabled: false },
33
+ render: (args) => (
34
+ <div className="bg-background relative h-80 w-[640px] overflow-hidden">
35
+ <ParticleBackground {...args} />
36
+ </div>
37
+ ),
38
+ };