@ponti-studios/ui 0.1.2 → 0.3.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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -7
  3. package/THIRD_PARTY_NOTICES.md +17 -0
  4. package/dist/index.js +1028 -638
  5. package/dist/index.js.map +1 -1
  6. package/package.json +30 -32
  7. package/src/animations.css +16 -434
  8. package/src/components/data-display/accordion.tsx +10 -7
  9. package/src/components/data-display/filter-chip.tsx +2 -2
  10. package/src/components/data-display/index.ts +5 -0
  11. package/src/components/data-display/metric-card.stories.tsx +48 -0
  12. package/src/components/data-display/metric-card.tsx +20 -0
  13. package/src/components/data-display/sort-controls.stories.tsx +45 -0
  14. package/src/components/data-display/sort-controls.tsx +99 -0
  15. package/src/components/data-display/sort-row.tsx +74 -0
  16. package/src/components/data-display/table.tsx +5 -5
  17. package/src/components/feedback/alert.tsx +4 -4
  18. package/src/components/feedback/empty-state.tsx +6 -6
  19. package/src/components/feedback/file-upload-status-badge.stories.tsx +28 -0
  20. package/src/components/feedback/file-upload-status-badge.tsx +33 -0
  21. package/src/components/feedback/file-upload-status.stories.tsx +57 -0
  22. package/src/components/feedback/file-upload-status.tsx +152 -0
  23. package/src/components/feedback/index.ts +9 -0
  24. package/src/components/feedback/progress-bar.stories.tsx +39 -0
  25. package/src/components/feedback/progress-bar.tsx +21 -0
  26. package/src/components/feedback/progress.stories.tsx +8 -6
  27. package/src/components/feedback/progress.tsx +1 -1
  28. package/src/components/feedback/skeleton.stories.tsx +36 -0
  29. package/src/components/feedback/skeleton.tsx +7 -0
  30. package/src/components/feedback/spinner.tsx +2 -2
  31. package/src/components/feedback/update-guard.stories.tsx +32 -0
  32. package/src/components/feedback/update-guard.tsx +280 -0
  33. package/src/components/forms/calendar.tsx +11 -11
  34. package/src/components/forms/date-month-select.stories.tsx +26 -0
  35. package/src/components/forms/date-month-select.tsx +70 -0
  36. package/src/components/forms/date-picker.tsx +3 -3
  37. package/src/components/forms/drop-zone.stories.tsx +56 -0
  38. package/src/components/forms/drop-zone.tsx +146 -0
  39. package/src/components/forms/entity-select.stories.tsx +65 -0
  40. package/src/components/forms/entity-select.tsx +79 -0
  41. package/src/components/forms/field.tsx +3 -3
  42. package/src/components/forms/group-by-select.stories.tsx +26 -0
  43. package/src/components/forms/group-by-select.tsx +47 -0
  44. package/src/components/forms/index.ts +10 -0
  45. package/src/components/forms/input.stories.tsx +4 -4
  46. package/src/components/forms/input.tsx +1 -1
  47. package/src/components/forms/passkey-management.stories.tsx +81 -0
  48. package/src/components/forms/passkey-management.tsx +138 -0
  49. package/src/components/forms/radio-group.tsx +14 -13
  50. package/src/components/forms/select.stories.tsx +4 -4
  51. package/src/components/forms/select.tsx +45 -53
  52. package/src/components/forms/slider.stories.tsx +5 -2
  53. package/src/components/forms/slider.tsx +27 -17
  54. package/src/components/forms/stepper.tsx +4 -4
  55. package/src/components/forms/switch.stories.tsx +1 -1
  56. package/src/components/forms/switch.tsx +2 -2
  57. package/src/components/forms/textarea.stories.tsx +2 -2
  58. package/src/components/forms/textarea.tsx +1 -1
  59. package/src/components/layout/index.ts +3 -0
  60. package/src/components/layout/scroll-area.stories.tsx +4 -4
  61. package/src/components/layout/scroll-area.tsx +23 -21
  62. package/src/components/layout/section-intro.stories.tsx +29 -0
  63. package/src/components/layout/section-intro.tsx +31 -0
  64. package/src/components/layout/surface-panel.stories.tsx +32 -0
  65. package/src/components/layout/surface-panel.tsx +15 -0
  66. package/src/components/navigation/app-navigation.stories.tsx +2 -2
  67. package/src/components/navigation/app-navigation.tsx +13 -13
  68. package/src/components/navigation/index.ts +4 -0
  69. package/src/components/navigation/pagination-controls.stories.tsx +41 -0
  70. package/src/components/navigation/pagination-controls.tsx +50 -0
  71. package/src/components/navigation/route-link.stories.tsx +30 -0
  72. package/src/components/navigation/route-link.tsx +42 -0
  73. package/src/components/navigation/tabs.stories.tsx +5 -5
  74. package/src/components/navigation/tabs.tsx +7 -7
  75. package/src/components/overlays/alert-dialog.tsx +21 -46
  76. package/src/components/overlays/command.tsx +51 -82
  77. package/src/components/overlays/dialog.tsx +5 -5
  78. package/src/components/overlays/dropdown-menu.stories.tsx +0 -5
  79. package/src/components/overlays/dropdown-menu.tsx +44 -142
  80. package/src/components/overlays/popover.stories.tsx +2 -2
  81. package/src/components/overlays/popover.tsx +34 -16
  82. package/src/components/overlays/sheet.stories.tsx +2 -2
  83. package/src/components/overlays/sheet.tsx +35 -17
  84. package/src/components/primitives/avatar.tsx +3 -3
  85. package/src/components/primitives/badge.tsx +7 -11
  86. package/src/components/primitives/button.tsx +41 -13
  87. package/src/components/primitives/card.stories.tsx +2 -2
  88. package/src/components/primitives/card.tsx +2 -2
  89. package/src/components/primitives/index.ts +2 -0
  90. package/src/components/primitives/label.stories.tsx +1 -1
  91. package/src/components/primitives/label.tsx +5 -6
  92. package/src/components/primitives/status-badge.stories.tsx +52 -0
  93. package/src/components/primitives/status-badge.tsx +33 -0
  94. package/src/constants/chart-colors.ts +5 -5
  95. package/src/hooks/index.ts +1 -0
  96. package/src/hooks/use-sort.ts +20 -0
  97. package/src/index.ts +30 -11
  98. package/src/styles.css +76 -1040
  99. package/src/tokens/color-systems.stories.tsx +9 -119
  100. package/src/tokens/foundations.stories.tsx +21 -113
  101. package/src/tokens/generated/dark.colors.generated.ts +56 -0
  102. package/src/tokens/generated/dark.css +54 -0
  103. package/src/tokens/generated/foundations.css +148 -0
  104. package/src/tokens/generated/foundations.generated.ts +443 -0
  105. package/src/tokens/generated/light.colors.generated.ts +56 -0
  106. package/src/tokens/generated/light.css +54 -0
  107. package/src/tokens/index.ts +74 -16
  108. package/src/tokens/layout.stories.tsx +3 -108
  109. package/src/tokens/colors.ts +0 -156
  110. package/src/tokens/motion.ts +0 -51
  111. package/src/tokens/radii.ts +0 -10
  112. package/src/tokens/shadows.ts +0 -34
  113. package/src/tokens/spacing.ts +0 -30
  114. package/src/tokens/typography.ts +0 -42
@@ -0,0 +1,146 @@
1
+ import type React from "react";
2
+ import { useCallback, useId } from "react";
3
+
4
+ import { cn } from "../../lib/utils";
5
+
6
+ export interface DropZoneProps {
7
+ isImporting: boolean;
8
+ dragActive: boolean;
9
+ onDrop: (files: File[]) => void;
10
+ onDragOver: () => void;
11
+ onDragLeave: () => void;
12
+ onChange?: (files: File[]) => void;
13
+ accept?: string;
14
+ multiple?: boolean;
15
+ className?: string;
16
+ label?: string;
17
+ helpText?: string;
18
+ }
19
+
20
+ /** Drag-and-drop file picker with a click-to-browse fallback. */
21
+ export function DropZone({
22
+ isImporting,
23
+ dragActive,
24
+ onDrop,
25
+ onDragOver,
26
+ onDragLeave,
27
+ onChange,
28
+ accept,
29
+ multiple = true,
30
+ className,
31
+ label = "Drag and drop files here, or",
32
+ helpText,
33
+ }: DropZoneProps) {
34
+ const inputId = useId();
35
+
36
+ const handleDrop = useCallback(
37
+ (e: React.DragEvent<HTMLButtonElement>) => {
38
+ e.preventDefault();
39
+ e.stopPropagation();
40
+ onDragLeave();
41
+
42
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
43
+ onDrop(Array.from(e.dataTransfer.files));
44
+ }
45
+ },
46
+ [onDrop, onDragLeave],
47
+ );
48
+
49
+ const triggerFileInput = useCallback(() => {
50
+ if (!isImporting) {
51
+ document.getElementById(inputId)?.click();
52
+ }
53
+ }, [isImporting, inputId]);
54
+
55
+ const handleDragOver = useCallback(
56
+ (e: React.DragEvent<HTMLButtonElement>) => {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ onDragOver();
60
+ },
61
+ [onDragOver],
62
+ );
63
+
64
+ const handleDragLeave = useCallback(
65
+ (e: React.DragEvent<HTMLButtonElement>) => {
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ onDragLeave();
69
+ },
70
+ [onDragLeave],
71
+ );
72
+
73
+ const handleClick = useCallback(
74
+ (e: React.MouseEvent<HTMLButtonElement>) => {
75
+ e.stopPropagation();
76
+ triggerFileInput();
77
+ },
78
+ [triggerFileInput],
79
+ );
80
+
81
+ const handleKeyDown = useCallback(
82
+ (e: React.KeyboardEvent<HTMLButtonElement>) => {
83
+ if (e.key === "Enter" || e.key === " ") {
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ triggerFileInput();
87
+ }
88
+ },
89
+ [triggerFileInput],
90
+ );
91
+
92
+ const handleInputChange = useCallback(
93
+ (e: React.ChangeEvent<HTMLInputElement>) => {
94
+ e.stopPropagation();
95
+ if (onChange) {
96
+ onChange(e.target.files ? Array.from(e.target.files) : []);
97
+ }
98
+ },
99
+ [onChange],
100
+ );
101
+
102
+ return (
103
+ <div>
104
+ <button
105
+ type="button"
106
+ className={cn(
107
+ "relative border-2 border-dashed p-12",
108
+ {
109
+ "border-primary bg-primary/5": dragActive,
110
+ "border-border bg-muted": !dragActive,
111
+ },
112
+ "flex flex-col items-center justify-center gap-4",
113
+ {
114
+ "pointer-events-none cursor-not-allowed": isImporting,
115
+ },
116
+ className,
117
+ )}
118
+ onDragOver={handleDragOver}
119
+ onDragLeave={handleDragLeave}
120
+ onDrop={handleDrop}
121
+ onClick={handleClick}
122
+ onKeyDown={handleKeyDown}
123
+ tabIndex={isImporting ? -1 : 0}
124
+ aria-disabled={isImporting}
125
+ aria-label="Upload file area. Press Enter or Space to open file browser"
126
+ >
127
+ <div className="space-y-2 text-center">
128
+ <p className="text-muted-foreground">
129
+ {label} <span className="text-foreground font-medium">click to browse</span>
130
+ </p>
131
+ {helpText ? <p className="text-muted-foreground text-sm">{helpText}</p> : null}
132
+ </div>
133
+ </button>
134
+ <input
135
+ id={inputId}
136
+ type="file"
137
+ multiple={multiple}
138
+ accept={accept}
139
+ onChange={handleInputChange}
140
+ className="sr-only"
141
+ disabled={isImporting}
142
+ aria-label="File input"
143
+ />
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,65 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import { EntitySelect } from "./entity-select";
5
+
6
+ const meta = {
7
+ title: "Forms/EntitySelect",
8
+ component: EntitySelect,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof EntitySelect>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ const options = [
16
+ { id: "checking", name: "Checking" },
17
+ { id: "savings", name: "Savings" },
18
+ { id: "credit-card", name: "Credit Card" },
19
+ ];
20
+
21
+ function EntitySelectPreview() {
22
+ const [value, setValue] = useState("all");
23
+ return (
24
+ <EntitySelect
25
+ value={value}
26
+ onValueChange={setValue}
27
+ options={options}
28
+ allOptionLabel="All accounts"
29
+ placeholder="All accounts"
30
+ label="Account"
31
+ showLabel
32
+ />
33
+ );
34
+ }
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ value: "all",
39
+ onValueChange: () => {},
40
+ options,
41
+ },
42
+ render: () => <EntitySelectPreview />,
43
+ };
44
+
45
+ export const Loading: Story = {
46
+ args: {
47
+ value: "all",
48
+ onValueChange: () => {},
49
+ options: [],
50
+ isLoading: true,
51
+ label: "Account",
52
+ showLabel: true,
53
+ },
54
+ };
55
+
56
+ export const Empty: Story = {
57
+ args: {
58
+ value: "all",
59
+ onValueChange: () => {},
60
+ options: [],
61
+ label: "Account",
62
+ showLabel: true,
63
+ emptyLabel: "No accounts available",
64
+ },
65
+ };
@@ -0,0 +1,79 @@
1
+ import { useId } from "react";
2
+
3
+ import { Label } from "../primitives/label";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
5
+
6
+ export interface EntitySelectOption {
7
+ id: string;
8
+ name: string;
9
+ }
10
+
11
+ export interface EntitySelectProps {
12
+ value: string;
13
+ onValueChange: (value: string) => void;
14
+ options: EntitySelectOption[];
15
+ isLoading?: boolean;
16
+ placeholder?: string;
17
+ label?: string;
18
+ showLabel?: boolean;
19
+ allOptionLabel?: string;
20
+ emptyLabel?: string;
21
+ className?: string;
22
+ }
23
+
24
+ /** Labeled `Select` over an `{ id, name }[]` list, with loading/empty states and an "all" option. */
25
+ export function EntitySelect({
26
+ value,
27
+ onValueChange,
28
+ options,
29
+ isLoading = false,
30
+ placeholder = "All",
31
+ label = "Filter",
32
+ showLabel = false,
33
+ allOptionLabel = "All",
34
+ emptyLabel = "No options available",
35
+ className,
36
+ }: EntitySelectProps) {
37
+ const id = useId();
38
+
39
+ const selectElement = (
40
+ <Select
41
+ name={label}
42
+ value={value}
43
+ onValueChange={(v: string | null) => v != null && onValueChange(v)}
44
+ >
45
+ <SelectTrigger id={id} className={className}>
46
+ <SelectValue placeholder={placeholder} />
47
+ </SelectTrigger>
48
+ <SelectContent className="max-h-[250px] overflow-y-auto">
49
+ <SelectItem value="all">{allOptionLabel}</SelectItem>
50
+ {isLoading ? (
51
+ <SelectItem value="loading" disabled>
52
+ Loading...
53
+ </SelectItem>
54
+ ) : options.length === 0 ? (
55
+ <SelectItem value="empty" disabled>
56
+ {emptyLabel}
57
+ </SelectItem>
58
+ ) : (
59
+ options.map((option) => (
60
+ <SelectItem key={option.id} value={option.id}>
61
+ {option.name}
62
+ </SelectItem>
63
+ ))
64
+ )}
65
+ </SelectContent>
66
+ </Select>
67
+ );
68
+
69
+ if (showLabel) {
70
+ return (
71
+ <div className="space-y-2">
72
+ <Label htmlFor={id}>{label}</Label>
73
+ {selectElement}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return selectElement;
79
+ }
@@ -29,7 +29,7 @@ function Field({ label, helpText, error, required, children, id: externalId }: F
29
29
  <label
30
30
  htmlFor={id}
31
31
  className={cn(
32
- "body-3 text-primary font-medium",
32
+ "text-sm text-foreground font-medium",
33
33
  required && "after:text-destructive after:ml-0.5 after:content-['*']",
34
34
  )}
35
35
  >
@@ -50,11 +50,11 @@ function Field({ label, helpText, error, required, children, id: externalId }: F
50
50
  : children}
51
51
 
52
52
  {error ? (
53
- <p id={errorId} role="alert" className="body-4 text-destructive">
53
+ <p id={errorId} role="alert" className="text-sm text-destructive">
54
54
  {error}
55
55
  </p>
56
56
  ) : helpText ? (
57
- <p id={descId} className="body-4 text-tertiary">
57
+ <p id={descId} className="text-sm text-muted-foreground">
58
58
  {helpText}
59
59
  </p>
60
60
  ) : null}
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import { GroupBySelect } from "./group-by-select";
5
+
6
+ const meta = {
7
+ title: "Forms/GroupBySelect",
8
+ component: GroupBySelect,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof GroupBySelect>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ function GroupBySelectPreview() {
16
+ const [groupBy, setGroupBy] = useState<"month" | "week" | "day">("month");
17
+ return <GroupBySelect groupBy={groupBy} onGroupByChange={setGroupBy} />;
18
+ }
19
+
20
+ export const Default: Story = {
21
+ args: {
22
+ groupBy: "month",
23
+ onGroupByChange: () => {},
24
+ },
25
+ render: () => <GroupBySelectPreview />,
26
+ };
@@ -0,0 +1,47 @@
1
+ import { useId } from "react";
2
+
3
+ import { Label } from "../primitives/label";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
5
+
6
+ type GroupByOption = "month" | "week" | "day";
7
+
8
+ export interface GroupBySelectProps {
9
+ groupBy: GroupByOption;
10
+ onGroupByChange: (value: GroupByOption) => void;
11
+ label?: string;
12
+ className?: string;
13
+ }
14
+
15
+ /** Month/week/day grouping select. */
16
+ export function GroupBySelect({
17
+ groupBy,
18
+ onGroupByChange,
19
+ label = "Group By",
20
+ className,
21
+ }: GroupBySelectProps) {
22
+ const id = useId();
23
+
24
+ return (
25
+ <div className={`space-y-2 ${className || ""}`}>
26
+ <Label htmlFor={id}>{label}</Label>
27
+ <Select
28
+ name="groupBy"
29
+ value={groupBy}
30
+ onValueChange={(value) => {
31
+ if (value === "month" || value === "week" || value === "day") {
32
+ onGroupByChange(value);
33
+ }
34
+ }}
35
+ >
36
+ <SelectTrigger id={id}>
37
+ <SelectValue />
38
+ </SelectTrigger>
39
+ <SelectContent>
40
+ <SelectItem value="month">Month</SelectItem>
41
+ <SelectItem value="week">Week</SelectItem>
42
+ <SelectItem value="day">Day</SelectItem>
43
+ </SelectContent>
44
+ </Select>
45
+ </div>
46
+ );
47
+ }
@@ -2,9 +2,19 @@ export { Calendar } from "./calendar";
2
2
  export type { CalendarProps } from "./calendar";
3
3
  export { DatePicker } from "./date-picker";
4
4
  export type { DatePickerProps } from "./date-picker";
5
+ export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
6
+ export type { DateMonthSelectProps } from "./date-month-select";
7
+ export { DropZone } from "./drop-zone";
8
+ export type { DropZoneProps } from "./drop-zone";
9
+ export { EntitySelect } from "./entity-select";
10
+ export type { EntitySelectOption, EntitySelectProps } from "./entity-select";
5
11
  export { Field } from "./field";
6
12
  export type { FieldProps } from "./field";
13
+ export { GroupBySelect } from "./group-by-select";
14
+ export type { GroupBySelectProps } from "./group-by-select";
7
15
  export { Input } from "./input";
16
+ export { PasskeyManagement } from "./passkey-management";
17
+ export type { PasskeyManagementProps, PasskeyRecord } from "./passkey-management";
8
18
  export { RadioGroup, RadioGroupItem } from "./radio-group";
9
19
  export {
10
20
  Select,
@@ -28,7 +28,7 @@ export const Default: Story = {
28
28
  export const WithLabel: Story = {
29
29
  render: () => (
30
30
  <div className="flex w-72 flex-col gap-2">
31
- <label htmlFor="email" className="text-primary text-sm leading-none font-medium">
31
+ <label htmlFor="email" className="text-foreground text-sm leading-none font-medium">
32
32
  Email
33
33
  </label>
34
34
  <Input id="email" type="email" placeholder="you@example.com" />
@@ -39,9 +39,9 @@ export const WithLabel: Story = {
39
39
  export const States: Story = {
40
40
  render: () => (
41
41
  <div className="flex w-72 flex-col gap-4">
42
- <Input placeholder="Default" />
43
- <Input placeholder="Disabled" disabled />
44
- <Input defaultValue="With value" />
42
+ <Input aria-label="Default input" placeholder="Default" />
43
+ <Input aria-label="Disabled input" placeholder="Disabled" disabled />
44
+ <Input aria-label="Input with value" defaultValue="With value" />
45
45
  </div>
46
46
  ),
47
47
  };
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "file:text-primary placeholder:text-secondary selection:bg-accent selection:text-on-accent bg-canvas focus-visible:ring-focus h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background focus-visible:ring-ring h-9 w-full min-w-0 rounded-md border border-input px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
12
  "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
13
13
  className,
14
14
  )}
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+
4
+ import { PasskeyManagement, type PasskeyRecord } from "./passkey-management";
5
+
6
+ const meta = {
7
+ title: "Forms/PasskeyManagement",
8
+ component: PasskeyManagement,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof PasskeyManagement>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ const initialPasskeys: PasskeyRecord[] = [
16
+ { id: "1", name: "MacBook Pro", createdAt: "2025-01-15T00:00:00.000Z" },
17
+ { id: "2", name: "iPhone", createdAt: "2025-03-02T00:00:00.000Z" },
18
+ ];
19
+
20
+ function PasskeyManagementPreview({ withError = false }: { withError?: boolean }) {
21
+ const [passkeys, setPasskeys] = useState(initialPasskeys);
22
+
23
+ return (
24
+ <div className="w-96">
25
+ <PasskeyManagement
26
+ passkeys={passkeys}
27
+ error={withError ? "Could not delete passkey. Please try again." : null}
28
+ onAdd={async () => {
29
+ setPasskeys((prev) => [
30
+ ...prev,
31
+ {
32
+ id: String(prev.length + 1),
33
+ name: "New device",
34
+ createdAt: new Date().toISOString(),
35
+ },
36
+ ]);
37
+ return true;
38
+ }}
39
+ onDelete={async (id) => {
40
+ setPasskeys((prev) => prev.filter((pk) => pk.id !== id));
41
+ return true;
42
+ }}
43
+ />
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export const Default: Story = {
49
+ args: {
50
+ passkeys: initialPasskeys,
51
+ onAdd: async () => true,
52
+ onDelete: async () => true,
53
+ },
54
+ render: () => <PasskeyManagementPreview />,
55
+ };
56
+
57
+ export const Empty: Story = {
58
+ args: {
59
+ passkeys: [],
60
+ onAdd: async () => true,
61
+ onDelete: async () => true,
62
+ },
63
+ };
64
+
65
+ export const Loading: Story = {
66
+ args: {
67
+ isLoading: true,
68
+ onAdd: async () => true,
69
+ onDelete: async () => true,
70
+ },
71
+ };
72
+
73
+ export const WithError: Story = {
74
+ args: {
75
+ passkeys: initialPasskeys,
76
+ error: "Could not delete passkey. Please try again.",
77
+ onAdd: async () => true,
78
+ onDelete: async () => true,
79
+ },
80
+ render: () => <PasskeyManagementPreview withError />,
81
+ };
@@ -0,0 +1,138 @@
1
+ import { KeyRound, Plus, Trash2 } from "lucide-react";
2
+ import { useCallback, useState } from "react";
3
+
4
+ import { Button } from "../primitives/button";
5
+
6
+ export interface PasskeyRecord {
7
+ id: string;
8
+ name?: string;
9
+ createdAt?: string;
10
+ }
11
+
12
+ export interface PasskeyManagementProps {
13
+ passkeys?: PasskeyRecord[];
14
+ isLoading?: boolean;
15
+ error?: string | null;
16
+ onAdd: () => Promise<boolean>;
17
+ onDelete: (id: string) => Promise<boolean>;
18
+ }
19
+
20
+ /** List, add, and remove passkeys. The host app owns the WebAuthn ceremony via `onAdd`/`onDelete`. */
21
+ export function PasskeyManagement({
22
+ passkeys: passkeysProp,
23
+ isLoading = false,
24
+ error: externalError,
25
+ onAdd,
26
+ onDelete,
27
+ }: PasskeyManagementProps) {
28
+ const [adding, setAdding] = useState(false);
29
+ const [deletingId, setDeletingId] = useState<string | null>(null);
30
+ const [actionError, setActionError] = useState<string | null>(null);
31
+
32
+ const passkeys = passkeysProp ?? [];
33
+ const error = externalError ?? actionError;
34
+
35
+ const handleAdd = useCallback(async () => {
36
+ setAdding(true);
37
+ setActionError(null);
38
+ try {
39
+ const success = await onAdd();
40
+ if (!success) {
41
+ setActionError("Passkey registration was cancelled or failed.");
42
+ }
43
+ } catch {
44
+ setActionError("An error occurred during passkey registration.");
45
+ } finally {
46
+ setAdding(false);
47
+ }
48
+ }, [onAdd]);
49
+
50
+ const handleDelete = useCallback(
51
+ async (id: string) => {
52
+ setDeletingId(id);
53
+ setActionError(null);
54
+ try {
55
+ const success = await onDelete(id);
56
+ if (!success) {
57
+ throw new Error("Failed to delete passkey");
58
+ }
59
+ } catch {
60
+ setActionError("Could not delete passkey. Please try again.");
61
+ } finally {
62
+ setDeletingId(null);
63
+ }
64
+ },
65
+ [onDelete],
66
+ );
67
+
68
+ return (
69
+ <section aria-labelledby="passkey-heading" className="space-y-4">
70
+ <div className="flex items-center justify-between">
71
+ <div>
72
+ <h2 id="passkey-heading" className="text-sm font-medium text-foreground">
73
+ Passkeys
74
+ </h2>
75
+ <p className="text-sm text-muted-foreground">
76
+ Sign in without a password using biometrics or a security key.
77
+ </p>
78
+ </div>
79
+ <Button
80
+ type="button"
81
+ variant="outline"
82
+ size="sm"
83
+ onClick={() => void handleAdd()}
84
+ disabled={adding}
85
+ aria-label="Add a passkey"
86
+ >
87
+ <Plus className="size-4" aria-hidden />
88
+ {adding ? "Adding..." : "Add passkey"}
89
+ </Button>
90
+ </div>
91
+
92
+ {error ? (
93
+ <p role="alert" className="text-destructive text-sm">
94
+ {error}
95
+ </p>
96
+ ) : null}
97
+
98
+ {isLoading ? (
99
+ <p className="text-muted-foreground text-sm">Loading passkeys...</p>
100
+ ) : passkeys.length === 0 ? (
101
+ <div className="border-border text-muted-foreground flex items-center gap-3 border border-dashed p-4 text-sm">
102
+ <KeyRound className="size-4 shrink-0" aria-hidden />
103
+ <span>No passkeys registered. Add one to sign in faster.</span>
104
+ </div>
105
+ ) : (
106
+ <ul className="space-y-2" aria-label="Registered passkeys">
107
+ {passkeys.map((pk) => (
108
+ <li
109
+ key={pk.id}
110
+ className="border-border flex items-center justify-between border px-4 py-3 text-sm"
111
+ >
112
+ <div className="flex items-center gap-3">
113
+ <KeyRound className="text-muted-foreground size-4 shrink-0" aria-hidden />
114
+ <div>
115
+ <span className="font-medium">{pk.name ?? "Passkey"}</span>
116
+ {pk.createdAt ? (
117
+ <span className="text-muted-foreground ml-2 text-xs">
118
+ Added {new Date(pk.createdAt).toLocaleDateString()}
119
+ </span>
120
+ ) : null}
121
+ </div>
122
+ </div>
123
+ <button
124
+ type="button"
125
+ onClick={() => void handleDelete(pk.id)}
126
+ disabled={deletingId === pk.id}
127
+ aria-label={`Remove passkey ${pk.name ?? pk.id}`}
128
+ className="hover:text-destructive text-muted-foreground focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
129
+ >
130
+ <Trash2 className="size-4" aria-hidden />
131
+ </button>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ )}
136
+ </section>
137
+ );
138
+ }