@otfdashkit/ui 0.1.1 → 0.1.3

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 (120) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +28 -41
  3. package/dist/index.js +972 -655
  4. package/dist/index.mjs +826 -525
  5. package/package.json +5 -4
  6. package/src/advanced/BulkActions/BulkActions.tsx +2 -2
  7. package/src/advanced/CommandBar/CommandBar.tsx +2 -2
  8. package/src/advanced/Filters/Filters.tsx +6 -6
  9. package/src/advanced/Kanban/Kanban.tsx +1 -1
  10. package/src/advanced/ToggleButton/ToggleButton.tsx +1 -1
  11. package/src/advanced/bulk-actions.tsx +1 -46
  12. package/src/advanced/command-bar.tsx +1 -115
  13. package/src/advanced/filters.tsx +1 -103
  14. package/src/advanced/kanban.tsx +1 -95
  15. package/src/advanced/toggle-button.tsx +1 -78
  16. package/src/blocks/ChatDetail/ChatDetail.tsx +4 -4
  17. package/src/blocks/FeedbackModal/FeedbackModal.tsx +3 -3
  18. package/src/blocks/FileCards/FileCards.tsx +2 -2
  19. package/src/blocks/FilesList/FilesList.tsx +3 -3
  20. package/src/blocks/IntegrationCard/IntegrationCard.tsx +3 -3
  21. package/src/blocks/InviteModal/InviteModal.tsx +7 -7
  22. package/src/blocks/ManageTagsModal/ManageTagsModal.tsx +4 -4
  23. package/src/blocks/MessagesCard/MessagesCard.tsx +3 -3
  24. package/src/blocks/MetricCard/MetricCard.tsx +3 -3
  25. package/src/blocks/NotificationSettings/NotificationSettings.tsx +4 -4
  26. package/src/blocks/OrganizationMenu/OrganizationMenu.tsx +30 -11
  27. package/src/blocks/RolesMenu/RolesMenu.tsx +4 -4
  28. package/src/blocks/SelectUsersModal/SelectUsersModal.tsx +5 -5
  29. package/src/blocks/SidebarLayoutDashboard/SidebarLayoutDashboard.tsx +5 -5
  30. package/src/blocks/SidebarLayoutGroups/SidebarLayoutGroups.tsx +2 -2
  31. package/src/blocks/SidebarLayoutMinimal/SidebarLayoutMinimal.tsx +3 -3
  32. package/src/blocks/SidebarLayoutSearch/SidebarLayoutSearch.tsx +3 -3
  33. package/src/blocks/SidebarLayoutUser/SidebarLayoutUser.tsx +4 -4
  34. package/src/blocks/SortableTaskList/SortableTaskList.tsx +3 -3
  35. package/src/blocks/StackedLayoutBranded/StackedLayoutBranded.tsx +4 -4
  36. package/src/blocks/StackedLayoutTabs/StackedLayoutTabs.tsx +4 -4
  37. package/src/blocks/TaskCard/TaskCard.tsx +4 -4
  38. package/src/blocks/UserMenu/UserMenu.tsx +3 -3
  39. package/src/blocks/WorkspaceMembers/WorkspaceMembers.tsx +5 -5
  40. package/src/blocks/chat-detail.tsx +1 -73
  41. package/src/blocks/feedback-modal.tsx +1 -68
  42. package/src/blocks/file-cards.tsx +1 -56
  43. package/src/blocks/files-list.tsx +1 -72
  44. package/src/blocks/integration-card.tsx +1 -50
  45. package/src/blocks/invite-modal.tsx +1 -69
  46. package/src/blocks/manage-tags-modal.tsx +1 -63
  47. package/src/blocks/messages-card.tsx +1 -59
  48. package/src/blocks/metric-card.tsx +1 -79
  49. package/src/blocks/notification-settings.tsx +1 -61
  50. package/src/blocks/organization-menu.tsx +1 -85
  51. package/src/blocks/roles-menu.tsx +1 -61
  52. package/src/blocks/select-users-modal.tsx +1 -64
  53. package/src/blocks/sidebar-layout-dashboard.tsx +1 -97
  54. package/src/blocks/sidebar-layout-groups.tsx +1 -79
  55. package/src/blocks/sidebar-layout-minimal.tsx +1 -73
  56. package/src/blocks/sidebar-layout-search.tsx +1 -79
  57. package/src/blocks/sidebar-layout-user.tsx +1 -82
  58. package/src/blocks/sortable-task-list.tsx +1 -70
  59. package/src/blocks/stacked-layout-branded.tsx +1 -64
  60. package/src/blocks/stacked-layout-tabs.tsx +1 -80
  61. package/src/blocks/task-card.tsx +1 -88
  62. package/src/blocks/user-menu.tsx +1 -62
  63. package/src/blocks/workspace-members.tsx +1 -61
  64. package/src/charts/sparkline.tsx +1 -1
  65. package/src/components/DataGrid/DataGrid.tsx +1 -1
  66. package/src/components/DataTable/DataTable.tsx +4 -4
  67. package/src/components/EmptyState/EmptyState.tsx +6 -22
  68. package/src/components/Persona/Persona.tsx +1 -1
  69. package/src/components/Stat/Stat.tsx +1 -1
  70. package/src/components/Stepper/Stepper.tsx +13 -2
  71. package/src/components/banner.tsx +1 -203
  72. package/src/components/breadcrumb.tsx +1 -58
  73. package/src/components/command-item.tsx +1 -36
  74. package/src/components/data-grid.tsx +1 -125
  75. package/src/components/data-table.tsx +1 -139
  76. package/src/components/empty-state.tsx +1 -31
  77. package/src/components/hotkeys.tsx +1 -31
  78. package/src/components/icon-badge.tsx +1 -35
  79. package/src/components/loading-overlay.tsx +1 -38
  80. package/src/components/persona.tsx +1 -38
  81. package/src/components/property-list.tsx +1 -29
  82. package/src/components/stat.tsx +1 -51
  83. package/src/components/stepper.tsx +1 -69
  84. package/src/components/structured-list.tsx +1 -51
  85. package/src/components/timeline.tsx +1 -69
  86. package/src/components/toaster.tsx +1 -42
  87. package/src/data/DataList.tsx +1 -1
  88. package/src/engagement/Tour/Tour.tsx +1 -1
  89. package/src/engagement/beacon.tsx +1 -50
  90. package/src/engagement/tour.tsx +1 -106
  91. package/src/forms/ArrayField/ArrayField.tsx +2 -2
  92. package/src/forms/AutoForm/AutoForm.tsx +9 -8
  93. package/src/forms/DatePicker/DatePicker.tsx +3 -3
  94. package/src/forms/FloatingLabelInput/FloatingLabelInput.tsx +104 -0
  95. package/src/forms/FloatingLabelInput/index.ts +1 -0
  96. package/src/forms/PasswordInput/PasswordInput.tsx +2 -2
  97. package/src/forms/SearchInput/SearchInput.tsx +2 -2
  98. package/src/forms/StepForm/StepForm.tsx +2 -2
  99. package/src/forms/array-field.tsx +1 -61
  100. package/src/forms/auto-form.tsx +1 -133
  101. package/src/forms/date-picker.tsx +1 -109
  102. package/src/forms/field.tsx +1 -62
  103. package/src/forms/file-upload.tsx +1 -101
  104. package/src/forms/floating-label-input.tsx +1 -0
  105. package/src/forms/form.tsx +1 -57
  106. package/src/forms/object-field.tsx +1 -53
  107. package/src/forms/password-input.tsx +1 -54
  108. package/src/forms/search-input.tsx +1 -64
  109. package/src/forms/step-form.tsx +1 -113
  110. package/src/index.ts +11 -3
  111. package/src/primitives/Button/Button.tsx +18 -0
  112. package/src/primitives/cart-line-item.tsx +81 -0
  113. package/src/primitives/code-block.tsx +1 -1
  114. package/src/primitives/comparison.tsx +1 -1
  115. package/src/primitives/empty-cart.tsx +52 -0
  116. package/src/primitives/price-tag.tsx +62 -0
  117. package/src/primitives/qr-code.tsx +5 -1
  118. package/src/primitives/quantity-stepper.tsx +92 -0
  119. package/src/primitives/variant-picker.tsx +74 -0
  120. package/src/themes/base.css +18 -0
@@ -1,113 +1 @@
1
- import React, { createContext, useContext, useState } from 'react'
2
- import { Button } from '../primitives/button'
3
- import { cn } from '../utils/cn'
4
-
5
- interface StepFormContextValue {
6
- currentStep: number
7
- totalSteps: number
8
- steps: string[]
9
- next: () => void
10
- prev: () => void
11
- isFirst: boolean
12
- isLast: boolean
13
- }
14
-
15
- const StepFormContext = createContext<StepFormContextValue | null>(null)
16
-
17
- function useStepForm() {
18
- const ctx = useContext(StepFormContext)
19
- if (!ctx) throw new Error('useStepForm must be used within StepForm')
20
- return ctx
21
- }
22
-
23
- export interface StepFormProps {
24
- steps: string[]
25
- onSubmit: () => void
26
- children: React.ReactNode
27
- className?: string
28
- }
29
-
30
- export function StepForm({ steps, onSubmit, children, className }: StepFormProps) {
31
- const [currentStep, setCurrentStep] = useState(0)
32
- const totalSteps = steps.length
33
-
34
- const ctx: StepFormContextValue = {
35
- currentStep,
36
- totalSteps,
37
- steps,
38
- next: () => setCurrentStep(s => Math.min(s + 1, totalSteps - 1)),
39
- prev: () => setCurrentStep(s => Math.max(s - 1, 0)),
40
- isFirst: currentStep === 0,
41
- isLast: currentStep === totalSteps - 1,
42
- }
43
-
44
- const childArray = React.Children.toArray(children)
45
- const currentChild = childArray[currentStep]
46
-
47
- return (
48
- <StepFormContext.Provider value={ctx}>
49
- <div className={cn('flex flex-col gap-6', className)}>
50
- <StepIndicator steps={steps} currentStep={currentStep} />
51
- <div>{currentChild}</div>
52
- <StepFormNavigation onSubmit={onSubmit} />
53
- </div>
54
- </StepFormContext.Provider>
55
- )
56
- }
57
-
58
- function StepIndicator({ steps, currentStep }: { steps: string[]; currentStep: number }) {
59
- return (
60
- <div className="flex items-center gap-2">
61
- {steps.map((label, i) => (
62
- <React.Fragment key={i}>
63
- {i > 0 && <div className={cn('h-px flex-1 bg-[hsl(var(--border))]', i <= currentStep && 'bg-[hsl(var(--primary))]')} />}
64
- <div className="flex flex-col items-center gap-1">
65
- <div className={cn(
66
- 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium border transition-colors',
67
- i < currentStep && 'bg-[hsl(var(--primary))] border-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]',
68
- i === currentStep && 'border-[hsl(var(--primary))] text-[hsl(var(--primary))]',
69
- i > currentStep && 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'
70
- )}>{i + 1}</div>
71
- <span className="hidden text-[10px] text-[hsl(var(--muted-foreground))] sm:block">{label}</span>
72
- </div>
73
- </React.Fragment>
74
- ))}
75
- </div>
76
- )
77
- }
78
-
79
- export interface StepFormStepProps {
80
- title: string
81
- description?: string
82
- children: React.ReactNode
83
- }
84
-
85
- export function StepFormStep({ title, description, children }: StepFormStepProps) {
86
- return (
87
- <div className="flex flex-col gap-4">
88
- <div>
89
- <h3 className="text-base font-semibold text-[hsl(var(--foreground))]">{title}</h3>
90
- {description && <p className="text-sm text-[hsl(var(--muted-foreground))]">{description}</p>}
91
- </div>
92
- {children}
93
- </div>
94
- )
95
- }
96
-
97
- export interface StepFormNavigationProps {
98
- onSubmit?: () => void
99
- submitLabel?: string
100
- }
101
-
102
- export function StepFormNavigation({ onSubmit, submitLabel = 'Submit' }: StepFormNavigationProps) {
103
- const { prev, next, isFirst, isLast } = useStepForm()
104
- return (
105
- <div className="flex justify-between">
106
- <Button type="button" variant="outline" onClick={prev} disabled={isFirst}>Previous</Button>
107
- {isLast
108
- ? <Button type="button" onClick={onSubmit}>{submitLabel}</Button>
109
- : <Button type="button" onClick={next}>Next</Button>
110
- }
111
- </div>
112
- )
113
- }
1
+ export * from './StepForm'
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from './primitives/button'
13
13
  export * from './primitives/calendar'
14
14
  export * from './primitives/card'
15
15
  export * from './primitives/carousel'
16
+ export * from './primitives/cart-line-item'
16
17
  export * from './primitives/chart'
17
18
  export * from './primitives/checkbox'
18
19
  export * from './primitives/code-block'
@@ -23,7 +24,8 @@ export * from './primitives/context-menu'
23
24
  export * from './primitives/dialog'
24
25
  export * from './primitives/drawer'
25
26
  export * from './primitives/dropdown-menu'
26
- export * from './primitives/form'
27
+ export * from './primitives/empty-cart'
28
+ // export * from './primitives/form' // conflicts with forms/form.tsx (Form, FormField, etc.)
27
29
  export * from './primitives/hover-card'
28
30
  export * from './primitives/input-otp'
29
31
  export * from './primitives/input'
@@ -34,8 +36,10 @@ export * from './primitives/menubar'
34
36
  export * from './primitives/navigation-menu'
35
37
  export * from './primitives/pagination'
36
38
  export * from './primitives/popover'
39
+ export * from './primitives/price-tag'
37
40
  export * from './primitives/progress'
38
41
  export * from './primitives/qr-code'
42
+ export * from './primitives/quantity-stepper'
39
43
  export * from './primitives/radio'
40
44
  export * from './primitives/rating'
41
45
  export * from './primitives/resizable'
@@ -55,11 +59,12 @@ export * from './primitives/texture-card'
55
59
  export * from './primitives/toggle-group'
56
60
  export * from './primitives/toggle'
57
61
  export * from './primitives/tooltip'
62
+ export * from './primitives/variant-picker'
58
63
 
59
64
  // components
60
65
  export * from './components/banner'
61
66
  export * from './components/breadcrumb'
62
- export * from './components/command-item'
67
+ export { CommandListItem } from './components/command-item'
63
68
  export * from './components/data-grid'
64
69
  export * from './components/data-table'
65
70
  export * from './components/empty-state'
@@ -132,6 +137,7 @@ export * from './forms/color-picker'
132
137
  export * from './forms/date-picker'
133
138
  export * from './forms/field'
134
139
  export * from './forms/file-upload'
140
+ export * from './forms/floating-label-input'
135
141
  export * from './forms/form'
136
142
  export * from './forms/object-field'
137
143
  export * from './forms/password-input'
@@ -153,7 +159,9 @@ export * from './layouts/stack'
153
159
  export { PageLayout } from './layout/PageLayout'
154
160
  export type { PageHeaderProps } from './layout/PageLayout'
155
161
  export { PageContainer, PageMain } from './layout/PageContainer'
156
- export * from './nav'
162
+ export type { CommandPaletteProps } from './nav/CommandPalette'
163
+ export { CommandPaletteProvider, useCommandPalette, CommandPalette } from './nav/CommandPalette'
164
+ export * from './nav/HotkeysProvider'
157
165
  export * from './feedback'
158
166
  export * from './text'
159
167
  export * from './backgrounds'
@@ -43,6 +43,24 @@ const buttonVariants = cva(
43
43
  'hover:border-border/60 hover:bg-accent',
44
44
  'active:shadow-none',
45
45
  ].join(' '),
46
+ /** Shimmer — animated conic gradient sweep with a hover highlight.
47
+ * Loud + premium; reserve for primary CTAs and pricing pages, not
48
+ * for in-product UI (gets tiring fast). Cross-platform parity with
49
+ * @otfdashkit/ui-native via the same `shimmer` variant key. */
50
+ shimmer: [
51
+ 'relative isolate overflow-hidden',
52
+ 'bg-primary text-primary-foreground',
53
+ 'shadow-[0_1px_0_rgba(255,255,255,0.18)_inset,0_-1px_0_rgba(0,0,0,0.25)_inset]',
54
+ 'hover:bg-primary/95',
55
+ // Continuous conic-gradient sweep behind the surface.
56
+ 'before:absolute before:inset-[-1px] before:rounded-[inherit] before:-z-10',
57
+ 'before:bg-[conic-gradient(from_0deg,transparent_0deg,transparent_70deg,rgba(255,255,255,0.55)_90deg,transparent_110deg,transparent_360deg)]',
58
+ 'before:animate-[shimmer-rotate_2.6s_linear_infinite]',
59
+ // Diagonal gloss that sweeps on hover.
60
+ 'after:absolute after:inset-0 after:rounded-[inherit] after:pointer-events-none',
61
+ 'after:bg-[linear-gradient(115deg,transparent_30%,rgba(255,255,255,0.22)_50%,transparent_70%)]',
62
+ 'after:translate-x-[-120%] hover:after:translate-x-[120%] after:transition-transform after:duration-[900ms] after:ease-out',
63
+ ].join(' '),
46
64
  },
47
65
  size: {
48
66
  sm: 'h-8 px-3 text-xs rounded-md',
@@ -0,0 +1,81 @@
1
+ import React from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { cn } from '../utils/cn'
4
+ import { Button } from './button'
5
+ import { PriceTag } from './price-tag'
6
+ import { QuantityStepper } from './quantity-stepper'
7
+
8
+ export type CartLineItemProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
9
+ /** Product thumbnail URL. */
10
+ image?: string
11
+ title: string
12
+ /** Variant summary line, e.g. "Whole bean · 250g". */
13
+ variant?: string
14
+ /** Unit price in minor units (cents). */
15
+ price: number
16
+ /** Optional compare-at unit price in minor units. */
17
+ compareAt?: number
18
+ currency?: string
19
+ quantity: number
20
+ onQuantityChange?: (qty: number) => void
21
+ onRemove?: () => void
22
+ minQuantity?: number
23
+ maxQuantity?: number
24
+ }
25
+
26
+ /** Cart row: thumbnail · title · variant · price · quantity stepper · remove. */
27
+ export const CartLineItem = React.forwardRef<HTMLDivElement, CartLineItemProps>(
28
+ (
29
+ {
30
+ image,
31
+ title,
32
+ variant,
33
+ price,
34
+ compareAt,
35
+ currency = 'USD',
36
+ quantity,
37
+ onQuantityChange,
38
+ onRemove,
39
+ minQuantity = 1,
40
+ maxQuantity = 99,
41
+ className,
42
+ ...props
43
+ },
44
+ ref,
45
+ ) => {
46
+ return (
47
+ <div
48
+ ref={ref}
49
+ className={cn('flex items-center gap-4 rounded-lg border border-border bg-card p-3', className)}
50
+ {...props}
51
+ >
52
+ {image && <img src={image} alt="" className="h-16 w-16 flex-none rounded-md object-cover" />}
53
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
54
+ <span className="truncate font-medium text-card-foreground">{title}</span>
55
+ {variant && <span className="truncate text-sm text-muted-foreground">{variant}</span>}
56
+ <PriceTag amount={price} compareAt={compareAt} currency={currency} size="sm" />
57
+ </div>
58
+ <QuantityStepper
59
+ value={quantity}
60
+ min={minQuantity}
61
+ max={maxQuantity}
62
+ onValueChange={onQuantityChange}
63
+ size="sm"
64
+ />
65
+ {onRemove && (
66
+ <Button
67
+ type="button"
68
+ variant="ghost"
69
+ size="icon"
70
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
71
+ onClick={onRemove}
72
+ aria-label={`Remove ${title}`}
73
+ >
74
+ <X className="h-4 w-4" />
75
+ </Button>
76
+ )}
77
+ </div>
78
+ )
79
+ },
80
+ )
81
+ CartLineItem.displayName = 'CartLineItem'
@@ -106,7 +106,7 @@ export const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
106
106
  return (
107
107
  <div
108
108
  ref={(node) => {
109
- containerRef.current = node
109
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node
110
110
  if (typeof ref === 'function') ref(node)
111
111
  else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
112
112
  }}
@@ -100,7 +100,7 @@ export const Comparison = React.forwardRef<HTMLDivElement, ComparisonProps>(
100
100
  return (
101
101
  <div
102
102
  ref={(node) => {
103
- containerRef.current = node
103
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node
104
104
  if (typeof ref === 'function') ref(node)
105
105
  else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
106
106
  }}
@@ -0,0 +1,52 @@
1
+ import React from 'react'
2
+ import { ShoppingCart } from 'lucide-react'
3
+ import { cn } from '../utils/cn'
4
+ import { Button } from './button'
5
+
6
+ export type EmptyCartProps = React.HTMLAttributes<HTMLDivElement> & {
7
+ title?: string
8
+ description?: string
9
+ /** CTA label — renders a button only when `onAction` is also provided. */
10
+ actionLabel?: string
11
+ onAction?: () => void
12
+ /** Override the default cart icon. */
13
+ icon?: React.ReactNode
14
+ }
15
+
16
+ /** Bespoke empty-cart state: soft icon chip, message, and a continue-shopping CTA. */
17
+ export const EmptyCart = React.forwardRef<HTMLDivElement, EmptyCartProps>(
18
+ (
19
+ {
20
+ title = 'Your cart is empty',
21
+ description = 'Browse the catalog and add something you love.',
22
+ actionLabel = 'Continue shopping',
23
+ onAction,
24
+ icon,
25
+ className,
26
+ ...props
27
+ },
28
+ ref,
29
+ ) => {
30
+ return (
31
+ <div
32
+ ref={ref}
33
+ className={cn('flex flex-col items-center justify-center gap-4 px-6 py-12 text-center', className)}
34
+ {...props}
35
+ >
36
+ <div className="flex h-14 w-14 items-center justify-center rounded-full bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]">
37
+ {icon ?? <ShoppingCart className="h-6 w-6" strokeWidth={1.5} />}
38
+ </div>
39
+ <div className="flex flex-col gap-1">
40
+ <span className="text-base font-semibold text-foreground">{title}</span>
41
+ {description && <span className="max-w-xs text-sm text-muted-foreground">{description}</span>}
42
+ </div>
43
+ {actionLabel && onAction && (
44
+ <Button onClick={onAction} className="mt-1">
45
+ {actionLabel}
46
+ </Button>
47
+ )}
48
+ </div>
49
+ )
50
+ },
51
+ )
52
+ EmptyCart.displayName = 'EmptyCart'
@@ -0,0 +1,62 @@
1
+ import React from 'react'
2
+ import { cn } from '../utils/cn'
3
+
4
+ const SIZE = {
5
+ sm: { price: 'text-sm', compare: 'text-xs' },
6
+ md: { price: 'text-lg', compare: 'text-sm' },
7
+ lg: { price: 'text-2xl', compare: 'text-base' },
8
+ } as const
9
+
10
+ export type PriceTagProps = React.HTMLAttributes<HTMLSpanElement> & {
11
+ /** Price in minor units (e.g. cents). */
12
+ amount: number
13
+ /** Original/compare-at price in minor units — renders struck-through when greater than `amount`. */
14
+ compareAt?: number
15
+ /** ISO 4217 currency code. Default 'USD'. */
16
+ currency?: string
17
+ /** BCP-47 locale for formatting. Default 'en-US'. */
18
+ locale?: string
19
+ /** Size scale. Default 'md'. */
20
+ size?: keyof typeof SIZE
21
+ /** Show the "−N%" discount pill when on sale. Default true. */
22
+ showDiscount?: boolean
23
+ }
24
+
25
+ function formatMoney(minor: number, currency: string, locale: string) {
26
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(minor / 100)
27
+ }
28
+
29
+ /**
30
+ * Formatted currency price with an optional compare-at strikethrough and discount pill.
31
+ * Amounts are minor units (cents) so there's no float drift.
32
+ */
33
+ export const PriceTag = React.forwardRef<HTMLSpanElement, PriceTagProps>(
34
+ (
35
+ { amount, compareAt, currency = 'USD', locale = 'en-US', size = 'md', showDiscount = true, className, ...props },
36
+ ref,
37
+ ) => {
38
+ const onSale = compareAt !== undefined && compareAt > amount
39
+ const pct = onSale ? Math.round((1 - amount / compareAt) * 100) : 0
40
+ const s = SIZE[size]
41
+ return (
42
+ <span ref={ref} className={cn('inline-flex items-baseline gap-2 font-sans tabular-nums', className)} {...props}>
43
+ <span className={cn('font-semibold tracking-tight text-foreground', s.price)}>
44
+ {formatMoney(amount, currency, locale)}
45
+ </span>
46
+ {onSale && (
47
+ <>
48
+ <span className={cn('text-muted-foreground line-through', s.compare)}>
49
+ {formatMoney(compareAt, currency, locale)}
50
+ </span>
51
+ {showDiscount && (
52
+ <span className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-semibold text-destructive">
53
+ −{pct}%
54
+ </span>
55
+ )}
56
+ </>
57
+ )}
58
+ </span>
59
+ )
60
+ },
61
+ )
62
+ PriceTag.displayName = 'PriceTag'
@@ -20,7 +20,11 @@ export type QrCodeProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>
20
20
  src: string
21
21
  height: number
22
22
  width: number
23
- excavate?: boolean
23
+ excavate: boolean
24
+ x?: number
25
+ y?: number
26
+ opacity?: number
27
+ crossOrigin?: 'anonymous' | 'use-credentials' | ''
24
28
  }
25
29
  }
26
30
 
@@ -0,0 +1,92 @@
1
+ import React from 'react'
2
+ import { Minus, Plus } from 'lucide-react'
3
+ import { cn } from '../utils/cn'
4
+ import { Button } from './button'
5
+
6
+ export type QuantityStepperProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
7
+ /** Controlled quantity. */
8
+ value?: number
9
+ /** Uncontrolled initial quantity. Default 1. */
10
+ defaultValue?: number
11
+ /** Fires with the clamped next quantity. */
12
+ onValueChange?: (value: number) => void
13
+ /** Minimum (decrement disables here). Default 1. */
14
+ min?: number
15
+ /** Maximum (increment disables here). Default 99. */
16
+ max?: number
17
+ /** Increment/decrement step. Default 1. */
18
+ step?: number
19
+ /** Control size. Default 'md'. */
20
+ size?: 'sm' | 'md'
21
+ disabled?: boolean
22
+ 'aria-label'?: string
23
+ }
24
+
25
+ /** Increment / decrement quantity control with min/max clamping. */
26
+ export const QuantityStepper = React.forwardRef<HTMLDivElement, QuantityStepperProps>(
27
+ (
28
+ {
29
+ value: valueProp,
30
+ defaultValue = 1,
31
+ onValueChange,
32
+ min = 1,
33
+ max = 99,
34
+ step = 1,
35
+ size = 'md',
36
+ disabled = false,
37
+ className,
38
+ 'aria-label': ariaLabel = 'Quantity',
39
+ ...props
40
+ },
41
+ ref,
42
+ ) => {
43
+ const isControlled = valueProp !== undefined
44
+ const [internal, setInternal] = React.useState(defaultValue)
45
+ const value = isControlled ? (valueProp as number) : internal
46
+ const commit = (next: number) => {
47
+ const clamped = Math.max(min, Math.min(max, next))
48
+ if (!isControlled) setInternal(clamped)
49
+ onValueChange?.(clamped)
50
+ }
51
+ const dim = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'
52
+ return (
53
+ <div
54
+ ref={ref}
55
+ role="group"
56
+ aria-label={ariaLabel}
57
+ className={cn('inline-flex items-center rounded-md border border-input', className)}
58
+ {...props}
59
+ >
60
+ <Button
61
+ type="button"
62
+ variant="ghost"
63
+ size="icon"
64
+ className={cn('rounded-r-none', dim)}
65
+ disabled={disabled || value <= min}
66
+ onClick={() => commit(value - step)}
67
+ aria-label="Decrease quantity"
68
+ >
69
+ <Minus className="h-4 w-4" />
70
+ </Button>
71
+ <span
72
+ aria-live="polite"
73
+ className={cn('select-none text-center text-sm font-medium tabular-nums', size === 'sm' ? 'min-w-7' : 'min-w-8')}
74
+ >
75
+ {value}
76
+ </span>
77
+ <Button
78
+ type="button"
79
+ variant="ghost"
80
+ size="icon"
81
+ className={cn('rounded-l-none', dim)}
82
+ disabled={disabled || value >= max}
83
+ onClick={() => commit(value + step)}
84
+ aria-label="Increase quantity"
85
+ >
86
+ <Plus className="h-4 w-4" />
87
+ </Button>
88
+ </div>
89
+ )
90
+ },
91
+ )
92
+ QuantityStepper.displayName = 'QuantityStepper'
@@ -0,0 +1,74 @@
1
+ import React from 'react'
2
+ import { cn } from '../utils/cn'
3
+
4
+ export type VariantOption = {
5
+ value: string
6
+ label: string
7
+ /** Optional color swatch — a CSS color from your product data (not a design token). */
8
+ swatch?: string
9
+ disabled?: boolean
10
+ }
11
+
12
+ export type VariantPickerProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
13
+ /** Group label, e.g. "Grind" or "Size". */
14
+ label?: string
15
+ options: VariantOption[]
16
+ /** Controlled selected value. */
17
+ value?: string
18
+ /** Uncontrolled initial value. */
19
+ defaultValue?: string
20
+ onValueChange?: (value: string) => void
21
+ }
22
+
23
+ /** Single-select product-option picker (grind, size, color) with selected + out-of-stock states. */
24
+ export const VariantPicker = React.forwardRef<HTMLDivElement, VariantPickerProps>(
25
+ ({ label, options, value: valueProp, defaultValue, onValueChange, className, ...props }, ref) => {
26
+ const isControlled = valueProp !== undefined
27
+ const [internal, setInternal] = React.useState(defaultValue)
28
+ const value = isControlled ? valueProp : internal
29
+ const select = (v: string) => {
30
+ if (!isControlled) setInternal(v)
31
+ onValueChange?.(v)
32
+ }
33
+ return (
34
+ <div ref={ref} role="radiogroup" aria-label={label} className={cn('flex flex-col gap-2', className)} {...props}>
35
+ {label && (
36
+ <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
37
+ )}
38
+ <div className="flex flex-wrap gap-2">
39
+ {options.map((opt) => {
40
+ const selected = value === opt.value
41
+ return (
42
+ <button
43
+ key={opt.value}
44
+ type="button"
45
+ role="radio"
46
+ aria-checked={selected}
47
+ disabled={opt.disabled}
48
+ onClick={() => select(opt.value)}
49
+ className={cn(
50
+ 'inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors',
51
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
52
+ selected
53
+ ? 'border-primary bg-primary/10 text-[hsl(var(--primary))]'
54
+ : 'border-border hover:bg-accent hover:text-accent-foreground',
55
+ opt.disabled && 'cursor-not-allowed text-muted-foreground line-through opacity-50',
56
+ )}
57
+ >
58
+ {opt.swatch && (
59
+ <span
60
+ aria-hidden="true"
61
+ className="h-4 w-4 rounded-full border border-border"
62
+ style={{ backgroundColor: opt.swatch }}
63
+ />
64
+ )}
65
+ {opt.label}
66
+ </button>
67
+ )
68
+ })}
69
+ </div>
70
+ </div>
71
+ )
72
+ },
73
+ )
74
+ VariantPicker.displayName = 'VariantPicker'
@@ -23,3 +23,21 @@ h1, h2, h3, h4, h5, h6 {
23
23
  line-height: var(--line-height-heading, 1.2);
24
24
  font-weight: var(--font-weight-heading, 600);
25
25
  }
26
+
27
+ /* ── Animations shipped with the SDK ────────────────────────────────────────
28
+ Consumers get these automatically by importing '@otfdashkit/ui/styles'.
29
+ Keep keyframe names unique to avoid collision with consumer-defined ones. */
30
+
31
+ /* Conic-gradient shimmer rotation — used by Button variant="shimmer".
32
+ Animation: 360deg sweep, 2.6s linear, infinite. */
33
+ @keyframes shimmer-rotate {
34
+ 0% { --shimmer-angle: 0deg; transform: rotate(0deg); }
35
+ 100% { --shimmer-angle: 360deg; transform: rotate(360deg); }
36
+ }
37
+
38
+ /* Concentric ripple expand — reserved for future use (RippleLoader, ping).
39
+ The existing motion/Ripple is click-triggered; this is for ambient pulses. */
40
+ @keyframes pulse-ring {
41
+ 0% { transform: scale(0.4); opacity: 0.8; }
42
+ 100% { transform: scale(1.6); opacity: 0; }
43
+ }