@open-mercato/ui 0.6.4-develop.4199.1.86677441c2 → 0.6.4-develop.4210.1.d412061cfe
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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- package/src/primitives/time-picker.tsx +9 -11
|
@@ -1,28 +1,289 @@
|
|
|
1
1
|
"use client"
|
|
2
|
+
|
|
2
3
|
import * as React from 'react'
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
5
|
+
|
|
3
6
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Determinate progress bar — both linear and circular. Linear is the
|
|
10
|
+
* default `Progress` export; circular is `CircularProgress`.
|
|
11
|
+
*
|
|
12
|
+
* Figma source: DS Open Mercato `Progress Bar` page (`450:17758`):
|
|
13
|
+
* `Progress Bar [1.1]` (`450:17821`), `Progress Bar Label [1.1]`
|
|
14
|
+
* (`515:3758`), `Progress Bar Line [1.1]` (`450:17810`, 5 tone
|
|
15
|
+
* variants: muted / accent / destructive / warning / success),
|
|
16
|
+
* `Circular Progress Bar [1.1]` (`466:4652`).
|
|
17
|
+
*
|
|
18
|
+
* Backward compatibility (Phase B rewrite): the original
|
|
19
|
+
* `<Progress value={n} max={100} className="..." />` API stays
|
|
20
|
+
* callable verbatim. The 3 existing call sites
|
|
21
|
+
* (`packages/ui/src/backend/NextStepCallout.tsx`,
|
|
22
|
+
* `packages/core/.../data_sync/.../runs/[id]/page.tsx`,
|
|
23
|
+
* `packages/sync-akeneo/.../akeneo-config/widget.client.tsx`)
|
|
24
|
+
* keep working without changes. New optional props (`size`, `tone`,
|
|
25
|
+
* `showValue`, `label`, `description`) are additive.
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* // Backward-compatible bare bar:
|
|
29
|
+
* <Progress value={50} />
|
|
30
|
+
*
|
|
31
|
+
* // Labelled variant per `Progress Bar Label [1.1]`:
|
|
32
|
+
* <Progress
|
|
33
|
+
* value={80}
|
|
34
|
+
* label="Data Storage"
|
|
35
|
+
* showValue
|
|
36
|
+
* description="Upgrade to unlock unlimited storage."
|
|
37
|
+
* />
|
|
38
|
+
*
|
|
39
|
+
* // Tone variants per `Progress Bar Line [1.1]`:
|
|
40
|
+
* <Progress value={42} tone="success" />
|
|
41
|
+
* <Progress value={42} tone="warning" />
|
|
42
|
+
* <Progress value={42} tone="destructive" />
|
|
43
|
+
*
|
|
44
|
+
* // Circular variant per `Circular Progress Bar [1.1]`:
|
|
45
|
+
* <CircularProgress value={75} size="default" showValue />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export type ProgressTone =
|
|
50
|
+
| 'accent'
|
|
51
|
+
| 'success'
|
|
52
|
+
| 'warning'
|
|
53
|
+
| 'destructive'
|
|
54
|
+
| 'muted'
|
|
55
|
+
|
|
56
|
+
const PROGRESS_TONE_FILL: Record<ProgressTone, string> = {
|
|
57
|
+
accent: 'bg-accent-indigo',
|
|
58
|
+
success: 'bg-status-success-icon',
|
|
59
|
+
warning: 'bg-status-warning-icon',
|
|
60
|
+
destructive: 'bg-status-error-icon',
|
|
61
|
+
muted: 'bg-muted-foreground',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const progressTrackVariants = cva(
|
|
65
|
+
'relative w-full overflow-hidden rounded-full bg-input',
|
|
66
|
+
{
|
|
67
|
+
variants: {
|
|
68
|
+
size: {
|
|
69
|
+
sm: 'h-1',
|
|
70
|
+
default: 'h-2',
|
|
71
|
+
lg: 'h-3',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
defaultVariants: { size: 'default' },
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const progressFillVariants = cva(
|
|
79
|
+
'h-full transition-all duration-300 ease-in-out',
|
|
80
|
+
{
|
|
81
|
+
variants: {
|
|
82
|
+
tone: {
|
|
83
|
+
accent: PROGRESS_TONE_FILL.accent,
|
|
84
|
+
success: PROGRESS_TONE_FILL.success,
|
|
85
|
+
warning: PROGRESS_TONE_FILL.warning,
|
|
86
|
+
destructive: PROGRESS_TONE_FILL.destructive,
|
|
87
|
+
muted: PROGRESS_TONE_FILL.muted,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
defaultVariants: { tone: 'accent' },
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
export interface ProgressProps
|
|
95
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
96
|
+
VariantProps<typeof progressTrackVariants> {
|
|
6
97
|
value: number
|
|
7
98
|
max?: number
|
|
99
|
+
/** Color of the filled portion. Default `accent` (indigo). */
|
|
100
|
+
tone?: ProgressTone
|
|
101
|
+
/** Show the percentage badge on the right of the label row. */
|
|
102
|
+
showValue?: boolean
|
|
103
|
+
/** Optional label rendered above the bar. */
|
|
104
|
+
label?: React.ReactNode
|
|
105
|
+
/** Optional description rendered below the bar in muted text. */
|
|
106
|
+
description?: React.ReactNode
|
|
107
|
+
/** Override the inner fill className (e.g. to apply a gradient). */
|
|
108
|
+
fillClassName?: string
|
|
8
109
|
}
|
|
9
110
|
|
|
10
|
-
export function Progress({
|
|
111
|
+
export function Progress({
|
|
112
|
+
value,
|
|
113
|
+
max = 100,
|
|
114
|
+
size,
|
|
115
|
+
tone,
|
|
116
|
+
showValue,
|
|
117
|
+
label,
|
|
118
|
+
description,
|
|
119
|
+
className,
|
|
120
|
+
fillClassName,
|
|
121
|
+
...props
|
|
122
|
+
}: ProgressProps) {
|
|
11
123
|
const percentage = Math.min(100, Math.max(0, Math.round((value / max) * 100)))
|
|
12
|
-
|
|
13
|
-
|
|
124
|
+
const hasLabelRow = label !== undefined || showValue === true
|
|
125
|
+
const bar = (
|
|
14
126
|
<div
|
|
15
127
|
role="progressbar"
|
|
16
128
|
aria-valuenow={value}
|
|
17
129
|
aria-valuemin={0}
|
|
18
130
|
aria-valuemax={max}
|
|
19
|
-
|
|
131
|
+
data-slot="progress"
|
|
132
|
+
data-tone={tone ?? 'accent'}
|
|
133
|
+
className={cn(progressTrackVariants({ size }), className)}
|
|
20
134
|
{...props}
|
|
21
135
|
>
|
|
22
136
|
<div
|
|
23
|
-
|
|
137
|
+
data-slot="progress-fill"
|
|
138
|
+
className={cn(progressFillVariants({ tone }), fillClassName)}
|
|
24
139
|
style={{ width: `${percentage}%` }}
|
|
25
140
|
/>
|
|
26
141
|
</div>
|
|
27
142
|
)
|
|
143
|
+
|
|
144
|
+
if (!hasLabelRow && description === undefined) return bar
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div data-slot="progress-wrapper" className="flex w-full flex-col gap-1.5">
|
|
148
|
+
{hasLabelRow ? (
|
|
149
|
+
<div className="flex items-center justify-between gap-3 text-sm font-medium text-foreground">
|
|
150
|
+
<span data-slot="progress-label" className="min-w-0 truncate">
|
|
151
|
+
{label}
|
|
152
|
+
</span>
|
|
153
|
+
{showValue ? (
|
|
154
|
+
<span
|
|
155
|
+
data-slot="progress-value"
|
|
156
|
+
className="shrink-0 text-muted-foreground"
|
|
157
|
+
>
|
|
158
|
+
{percentage}%
|
|
159
|
+
</span>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
{bar}
|
|
164
|
+
{description !== undefined ? (
|
|
165
|
+
<div data-slot="progress-description" className="text-sm text-muted-foreground">
|
|
166
|
+
{description}
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------
|
|
174
|
+
// CircularProgress
|
|
175
|
+
// ---------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const CIRCULAR_SIZE_MAP = {
|
|
178
|
+
xs: { box: 24, stroke: 3, textClass: 'text-[9px]' },
|
|
179
|
+
sm: { box: 32, stroke: 3, textClass: 'text-[10px]' },
|
|
180
|
+
default: { box: 48, stroke: 4, textClass: 'text-xs' },
|
|
181
|
+
lg: { box: 64, stroke: 5, textClass: 'text-sm' },
|
|
182
|
+
} as const
|
|
183
|
+
|
|
184
|
+
export type CircularProgressSize = keyof typeof CIRCULAR_SIZE_MAP
|
|
185
|
+
|
|
186
|
+
const CIRCULAR_TONE_STROKE: Record<ProgressTone, string> = {
|
|
187
|
+
accent: 'stroke-accent-indigo',
|
|
188
|
+
success: 'stroke-status-success-icon',
|
|
189
|
+
warning: 'stroke-status-warning-icon',
|
|
190
|
+
destructive: 'stroke-status-error-icon',
|
|
191
|
+
muted: 'stroke-muted-foreground',
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CircularProgressProps {
|
|
195
|
+
value: number
|
|
196
|
+
max?: number
|
|
197
|
+
size?: CircularProgressSize
|
|
198
|
+
tone?: ProgressTone
|
|
199
|
+
/** Render the percentage in the centre of the ring. */
|
|
200
|
+
showValue?: boolean
|
|
201
|
+
/** Override the inner content (replaces the default percentage label
|
|
202
|
+
* when `showValue` is true). Renders only when `showValue` is true. */
|
|
203
|
+
children?: React.ReactNode
|
|
204
|
+
/** Accessible label for the progressbar. Falls back to `${percentage}%`. */
|
|
205
|
+
ariaLabel?: string
|
|
206
|
+
className?: string
|
|
207
|
+
/** Override the track ring className (defaults to `stroke-input`). */
|
|
208
|
+
trackClassName?: string
|
|
209
|
+
/** Override the fill ring className (defaults per `tone`). */
|
|
210
|
+
fillClassName?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function CircularProgress({
|
|
214
|
+
value,
|
|
215
|
+
max = 100,
|
|
216
|
+
size = 'default',
|
|
217
|
+
tone = 'accent',
|
|
218
|
+
showValue,
|
|
219
|
+
children,
|
|
220
|
+
ariaLabel,
|
|
221
|
+
className,
|
|
222
|
+
trackClassName,
|
|
223
|
+
fillClassName,
|
|
224
|
+
}: CircularProgressProps) {
|
|
225
|
+
const percentage = Math.min(100, Math.max(0, Math.round((value / max) * 100)))
|
|
226
|
+
const { box, stroke, textClass } = CIRCULAR_SIZE_MAP[size]
|
|
227
|
+
const radius = (box - stroke) / 2
|
|
228
|
+
const circumference = 2 * Math.PI * radius
|
|
229
|
+
const dashOffset = circumference * (1 - percentage / 100)
|
|
230
|
+
const half = box / 2
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
role="progressbar"
|
|
234
|
+
aria-valuenow={value}
|
|
235
|
+
aria-valuemin={0}
|
|
236
|
+
aria-valuemax={max}
|
|
237
|
+
aria-label={ariaLabel ?? `${percentage}%`}
|
|
238
|
+
data-slot="circular-progress"
|
|
239
|
+
data-tone={tone}
|
|
240
|
+
data-size={size}
|
|
241
|
+
className={cn('relative inline-flex items-center justify-center', className)}
|
|
242
|
+
>
|
|
243
|
+
<svg
|
|
244
|
+
width={box}
|
|
245
|
+
height={box}
|
|
246
|
+
viewBox={`0 0 ${box} ${box}`}
|
|
247
|
+
className="-rotate-90"
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
>
|
|
250
|
+
<circle
|
|
251
|
+
data-slot="circular-progress-track"
|
|
252
|
+
cx={half}
|
|
253
|
+
cy={half}
|
|
254
|
+
r={radius}
|
|
255
|
+
fill="none"
|
|
256
|
+
strokeWidth={stroke}
|
|
257
|
+
className={cn('stroke-input', trackClassName)}
|
|
258
|
+
/>
|
|
259
|
+
<circle
|
|
260
|
+
data-slot="circular-progress-fill"
|
|
261
|
+
cx={half}
|
|
262
|
+
cy={half}
|
|
263
|
+
r={radius}
|
|
264
|
+
fill="none"
|
|
265
|
+
strokeWidth={stroke}
|
|
266
|
+
strokeLinecap="round"
|
|
267
|
+
strokeDasharray={circumference}
|
|
268
|
+
strokeDashoffset={dashOffset}
|
|
269
|
+
className={cn(
|
|
270
|
+
CIRCULAR_TONE_STROKE[tone],
|
|
271
|
+
'transition-all duration-300 ease-in-out',
|
|
272
|
+
fillClassName,
|
|
273
|
+
)}
|
|
274
|
+
/>
|
|
275
|
+
</svg>
|
|
276
|
+
{showValue ? (
|
|
277
|
+
<span
|
|
278
|
+
data-slot="circular-progress-value"
|
|
279
|
+
className={cn(
|
|
280
|
+
'absolute inset-0 inline-flex items-center justify-center font-medium text-foreground',
|
|
281
|
+
textClass,
|
|
282
|
+
)}
|
|
283
|
+
>
|
|
284
|
+
{children ?? `${percentage}%`}
|
|
285
|
+
</span>
|
|
286
|
+
) : null}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
28
289
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Circle, Heart, Star, StarHalf } from 'lucide-react'
|
|
5
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
6
|
+
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 1-to-N star / heart / dot rating per Figma `Rating & Review [1.0]`
|
|
12
|
+
* (DS Open Mercato componentSet `199969:1797`). Two distinct modes:
|
|
13
|
+
*
|
|
14
|
+
* - **Read-only display** — no `onChange` prop. Renders `role="img"` with
|
|
15
|
+
* an `aria-label` like "4.5 out of 5 stars". Used in product reviews,
|
|
16
|
+
* feedback summaries.
|
|
17
|
+
* - **Interactive input** — `onChange` provided. Renders as a row of
|
|
18
|
+
* focusable buttons; arrow keys / number keys navigate, click /
|
|
19
|
+
* Enter / Space commits. Used in submission forms.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* // Read-only
|
|
23
|
+
* <Rating value={4.5} max={5} />
|
|
24
|
+
*
|
|
25
|
+
* // Interactive
|
|
26
|
+
* const [v, setV] = React.useState(0)
|
|
27
|
+
* <Rating value={v} onChange={setV} aria-label="Your rating" />
|
|
28
|
+
*
|
|
29
|
+
* // Half precision (stars only — Lucide ships StarHalf but not HeartHalf)
|
|
30
|
+
* <Rating value={3.5} max={5} allowHalf />
|
|
31
|
+
*
|
|
32
|
+
* // Heart variant
|
|
33
|
+
* <Rating value={3} max={5} icon="heart" />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const ratingRootVariants = cva('inline-flex items-center gap-0.5', {
|
|
38
|
+
variants: {
|
|
39
|
+
size: {
|
|
40
|
+
sm: '[&>*]:size-4',
|
|
41
|
+
default: '[&>*]:size-5',
|
|
42
|
+
lg: '[&>*]:size-6',
|
|
43
|
+
},
|
|
44
|
+
disabled: {
|
|
45
|
+
true: 'cursor-not-allowed opacity-60',
|
|
46
|
+
false: '',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
defaultVariants: { size: 'default', disabled: false },
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const ratingItemColorVariants = cva('transition-colors', {
|
|
53
|
+
variants: {
|
|
54
|
+
fill: {
|
|
55
|
+
full: 'fill-status-warning-icon text-status-warning-icon',
|
|
56
|
+
half: 'fill-status-warning-icon text-status-warning-icon',
|
|
57
|
+
empty: 'fill-transparent text-muted-foreground/30',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
defaultVariants: { fill: 'empty' },
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
type FillState = 'full' | 'half' | 'empty'
|
|
64
|
+
|
|
65
|
+
function resolveFillState(index: number, value: number, allowHalf: boolean): FillState {
|
|
66
|
+
if (value >= index + 1) return 'full'
|
|
67
|
+
if (allowHalf && value >= index + 0.5) return 'half'
|
|
68
|
+
return 'empty'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function StarFull(props: React.ComponentProps<typeof Star>) {
|
|
72
|
+
return <Star aria-hidden="true" {...props} />
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function StarHalfIcon(props: React.ComponentProps<typeof StarHalf>) {
|
|
76
|
+
return <StarHalf aria-hidden="true" {...props} />
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function HeartIcon(props: React.ComponentProps<typeof Heart>) {
|
|
80
|
+
return <Heart aria-hidden="true" {...props} />
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function CircleIcon(props: React.ComponentProps<typeof Circle>) {
|
|
84
|
+
return <Circle aria-hidden="true" {...props} />
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderIcon(
|
|
88
|
+
iconType: 'star' | 'heart' | 'circle',
|
|
89
|
+
fill: FillState,
|
|
90
|
+
className: string,
|
|
91
|
+
): React.ReactElement {
|
|
92
|
+
if (iconType === 'heart') {
|
|
93
|
+
// Lucide does not ship HeartHalf — half precision is treated as full
|
|
94
|
+
// for hearts (consumer should set allowHalf=false when icon="heart").
|
|
95
|
+
return <HeartIcon className={className} />
|
|
96
|
+
}
|
|
97
|
+
if (iconType === 'circle') {
|
|
98
|
+
return <CircleIcon className={className} />
|
|
99
|
+
}
|
|
100
|
+
// star
|
|
101
|
+
if (fill === 'half') return <StarHalfIcon className={className} />
|
|
102
|
+
return <StarFull className={className} />
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type RatingProps = Omit<React.HTMLAttributes<HTMLSpanElement>, 'onChange'> &
|
|
106
|
+
VariantProps<typeof ratingRootVariants> & {
|
|
107
|
+
/** Current rating value (0..max). Floats allowed when `allowHalf` is `true`. */
|
|
108
|
+
value: number
|
|
109
|
+
/** Total number of items. Defaults to 5. */
|
|
110
|
+
max?: number
|
|
111
|
+
/** Optional change handler. Presence flips the primitive into interactive mode. */
|
|
112
|
+
onChange?: (next: number) => void
|
|
113
|
+
/** Which icon glyph to render. Defaults to `'star'`. */
|
|
114
|
+
icon?: 'star' | 'heart' | 'circle'
|
|
115
|
+
/** Enable half-step precision (stars only — Lucide has no HeartHalf). */
|
|
116
|
+
allowHalf?: boolean
|
|
117
|
+
/** When true, dim the control and block clicks. Inherited via `disabled` prop on the root. */
|
|
118
|
+
disabled?: boolean
|
|
119
|
+
/** Required when interactive — screen-readers announce this as the group label. */
|
|
120
|
+
'aria-label'?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const Rating = React.forwardRef<HTMLSpanElement, RatingProps>(
|
|
124
|
+
(
|
|
125
|
+
{
|
|
126
|
+
className,
|
|
127
|
+
value,
|
|
128
|
+
max = 5,
|
|
129
|
+
onChange,
|
|
130
|
+
icon = 'star',
|
|
131
|
+
allowHalf = false,
|
|
132
|
+
size,
|
|
133
|
+
disabled,
|
|
134
|
+
...rest
|
|
135
|
+
},
|
|
136
|
+
ref,
|
|
137
|
+
) => {
|
|
138
|
+
const t = useT()
|
|
139
|
+
const interactive = typeof onChange === 'function'
|
|
140
|
+
const handleSelect = React.useCallback(
|
|
141
|
+
(next: number) => {
|
|
142
|
+
if (disabled) return
|
|
143
|
+
onChange?.(next)
|
|
144
|
+
},
|
|
145
|
+
[disabled, onChange],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// Read-only display path — plain span container with aria-label.
|
|
149
|
+
if (!interactive) {
|
|
150
|
+
return (
|
|
151
|
+
<span
|
|
152
|
+
ref={ref}
|
|
153
|
+
role="img"
|
|
154
|
+
aria-label={rest['aria-label'] ?? t('ui.rating.summary.ariaLabel', '{value} out of {max}', { value, max })}
|
|
155
|
+
data-slot="rating"
|
|
156
|
+
className={cn(ratingRootVariants({ size, disabled }), className)}
|
|
157
|
+
{...rest}
|
|
158
|
+
>
|
|
159
|
+
{Array.from({ length: max }).map((_, index) => {
|
|
160
|
+
const fill = resolveFillState(index, value, allowHalf)
|
|
161
|
+
return (
|
|
162
|
+
<span
|
|
163
|
+
key={index}
|
|
164
|
+
data-slot="rating-item"
|
|
165
|
+
data-fill={fill}
|
|
166
|
+
className="inline-flex items-center justify-center"
|
|
167
|
+
>
|
|
168
|
+
{renderIcon(icon, fill, ratingItemColorVariants({ fill }))}
|
|
169
|
+
</span>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</span>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Interactive path — N buttons. Arrow keys move focus between items;
|
|
177
|
+
// Home / End jump to first / last. Click / Enter / Space commits the
|
|
178
|
+
// value (index + 1 for full clicks; index + 0.5 if allowHalf && the
|
|
179
|
+
// click landed on the left half of the icon).
|
|
180
|
+
return (
|
|
181
|
+
<span
|
|
182
|
+
ref={ref}
|
|
183
|
+
role="radiogroup"
|
|
184
|
+
aria-label={rest['aria-label']}
|
|
185
|
+
data-slot="rating"
|
|
186
|
+
className={cn(ratingRootVariants({ size, disabled }), className)}
|
|
187
|
+
{...rest}
|
|
188
|
+
>
|
|
189
|
+
{Array.from({ length: max }).map((_, index) => {
|
|
190
|
+
const fill = resolveFillState(index, value, allowHalf)
|
|
191
|
+
// Empty rating (value === 0) falls back to the first item so it stays Tab-reachable.
|
|
192
|
+
const isCurrent = value === 0 ? index === 0 : Math.ceil(value) - 1 === index
|
|
193
|
+
return (
|
|
194
|
+
<button
|
|
195
|
+
key={index}
|
|
196
|
+
type="button"
|
|
197
|
+
role="radio"
|
|
198
|
+
aria-checked={fill !== 'empty'}
|
|
199
|
+
aria-label={t('ui.rating.item.ariaLabel', '{position} of {max}', { position: index + 1, max })}
|
|
200
|
+
tabIndex={isCurrent ? 0 : -1}
|
|
201
|
+
data-slot="rating-item"
|
|
202
|
+
data-fill={fill}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
onClick={(event) => {
|
|
205
|
+
if (!allowHalf) {
|
|
206
|
+
handleSelect(index + 1)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
210
|
+
const isLeftHalf = event.clientX - rect.left < rect.width / 2
|
|
211
|
+
handleSelect(isLeftHalf ? index + 0.5 : index + 1)
|
|
212
|
+
}}
|
|
213
|
+
onKeyDown={(event) => {
|
|
214
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
|
215
|
+
event.preventDefault()
|
|
216
|
+
handleSelect(Math.min(max, value + (allowHalf ? 0.5 : 1)))
|
|
217
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
|
218
|
+
event.preventDefault()
|
|
219
|
+
handleSelect(Math.max(0, value - (allowHalf ? 0.5 : 1)))
|
|
220
|
+
} else if (event.key === 'Home') {
|
|
221
|
+
event.preventDefault()
|
|
222
|
+
handleSelect(allowHalf ? 0.5 : 1)
|
|
223
|
+
} else if (event.key === 'End') {
|
|
224
|
+
event.preventDefault()
|
|
225
|
+
handleSelect(max)
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
className={cn(
|
|
229
|
+
'inline-flex items-center justify-center rounded-sm outline-none',
|
|
230
|
+
'focus-visible:shadow-focus',
|
|
231
|
+
'enabled:hover:scale-110 enabled:hover:transition-transform',
|
|
232
|
+
'disabled:cursor-not-allowed',
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{renderIcon(icon, fill, ratingItemColorVariants({ fill }))}
|
|
236
|
+
</button>
|
|
237
|
+
)
|
|
238
|
+
})}
|
|
239
|
+
</span>
|
|
240
|
+
)
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
Rating.displayName = 'Rating'
|
|
244
|
+
|
|
245
|
+
export { ratingRootVariants, ratingItemColorVariants }
|