@open-mercato/ui 0.6.1-develop.3278.1.a4d26475e4 β 0.6.1-develop.3291.1.6fad645fd0
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 +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/website-input.tsx +72 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { CreditCard } from 'lucide-react'
|
|
5
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
9
|
+
|
|
10
|
+
export type CardBrand = {
|
|
11
|
+
/** Stable identifier (e.g. `'visa'`, `'mastercard'`). Used as the `data-card-brand` attribute. */
|
|
12
|
+
id: string
|
|
13
|
+
/** Short label rendered on the trailing badge (e.g. `'VISA'`, `'MC'`). */
|
|
14
|
+
label: string
|
|
15
|
+
/** Regex pattern matched against the digits-only card number. */
|
|
16
|
+
regex: RegExp
|
|
17
|
+
/** Grouping for display (e.g. `[4, 4, 4, 4]` for Visa, `[4, 6, 5]` for Amex). */
|
|
18
|
+
format: number[]
|
|
19
|
+
/** Maximum number of digits allowed (Visa/MC/Disc 16, Amex 15, Diners 14). */
|
|
20
|
+
maxLength: number
|
|
21
|
+
/** Background color for the brand badge. */
|
|
22
|
+
bg: string
|
|
23
|
+
/** Foreground (text) color for the brand badge label. Defaults to white. */
|
|
24
|
+
fg?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default card brand list β covers the most common networks. Order matters: the first matching
|
|
29
|
+
* regex wins, so brands with broader prefixes (e.g. UnionPay `^62`) MUST come after narrower ones
|
|
30
|
+
* (e.g. Discover `^622`). Override with the `brands` prop for region-specific surfaces.
|
|
31
|
+
*/
|
|
32
|
+
export const CARD_BRANDS: CardBrand[] = [
|
|
33
|
+
{ id: 'amex', label: 'AMEX', regex: /^3[47]/, format: [4, 6, 5], maxLength: 15, bg: '#006FCF' },
|
|
34
|
+
{ id: 'visa', label: 'VISA', regex: /^4/, format: [4, 4, 4, 4], maxLength: 16, bg: '#1A1F71' },
|
|
35
|
+
{ id: 'mastercard', label: 'MC', regex: /^(5[1-5]|2[2-7])/, format: [4, 4, 4, 4], maxLength: 16, bg: '#EB001B' },
|
|
36
|
+
{ id: 'discover', label: 'DISC', regex: /^(6011|64[4-9]|65|622)/, format: [4, 4, 4, 4], maxLength: 16, bg: '#FF6000' },
|
|
37
|
+
{ id: 'diners', label: 'DC', regex: /^(36|30[0-5]|309)/, format: [4, 6, 4], maxLength: 14, bg: '#0079BE' },
|
|
38
|
+
{ id: 'jcb', label: 'JCB', regex: /^35(2[89]|[3-8])/, format: [4, 4, 4, 4], maxLength: 16, bg: '#0E4C96' },
|
|
39
|
+
{ id: 'unionpay', label: 'UP', regex: /^62/, format: [4, 4, 4, 4], maxLength: 16, bg: '#E21836' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FORMAT: number[] = [4, 4, 4, 4]
|
|
43
|
+
const DEFAULT_MAX_LENGTH = 16
|
|
44
|
+
|
|
45
|
+
function detectBrand(digits: string, brands: CardBrand[]): CardBrand | null {
|
|
46
|
+
if (!digits) return null
|
|
47
|
+
for (const brand of brands) {
|
|
48
|
+
if (brand.regex.test(digits)) return brand
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatDigits(digits: string, format: number[]): string {
|
|
54
|
+
if (!digits) return ''
|
|
55
|
+
const groups: string[] = []
|
|
56
|
+
let cursor = 0
|
|
57
|
+
for (const groupSize of format) {
|
|
58
|
+
if (cursor >= digits.length) break
|
|
59
|
+
groups.push(digits.slice(cursor, cursor + groupSize))
|
|
60
|
+
cursor += groupSize
|
|
61
|
+
}
|
|
62
|
+
if (cursor < digits.length) groups.push(digits.slice(cursor))
|
|
63
|
+
return groups.join(' ')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitize(input: string, maxLength: number): string {
|
|
67
|
+
return input.replace(/\D+/g, '').slice(0, maxLength)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CardInputProps = Omit<
|
|
71
|
+
React.ComponentPropsWithoutRef<'input'>,
|
|
72
|
+
'size' | 'type' | 'value' | 'onChange'
|
|
73
|
+
> &
|
|
74
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
75
|
+
/** Controlled value β digits-only (no spaces). Pass formatted strings if you must; non-digits are stripped. */
|
|
76
|
+
value: string
|
|
77
|
+
/** Called with the new digits-only string. The component handles formatting on display. */
|
|
78
|
+
onChange: (digits: string) => void
|
|
79
|
+
/** Called when the detected brand changes. `null` when nothing matches yet. */
|
|
80
|
+
onBrandChange?: (brand: CardBrand | null) => void
|
|
81
|
+
/** Override the card brand list (e.g. region-specific). */
|
|
82
|
+
brands?: CardBrand[]
|
|
83
|
+
/** Render the leading bank-card icon. Defaults to `true`. */
|
|
84
|
+
showLeadingIcon?: boolean
|
|
85
|
+
/** Render the trailing brand badge. Defaults to `true`. */
|
|
86
|
+
showBrandBadge?: boolean
|
|
87
|
+
/** Optional className on the wrapper. */
|
|
88
|
+
className?: string
|
|
89
|
+
/** Optional className on the inner `<input>`. */
|
|
90
|
+
inputClassName?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Card-number input matching Figma `Text Input [1.1]` (node `266:5251`) **Card** variant β leading
|
|
95
|
+
* `CreditCard` icon, the formatted card-number input, and a trailing brand badge that auto-detects
|
|
96
|
+
* the issuer (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay) from the typed digits.
|
|
97
|
+
*
|
|
98
|
+
* Built on the shared `inputWrapperVariants` / `inputElementVariants` CVA. Brand detection is
|
|
99
|
+
* regex-based β no external library dependency. The `value` prop is digits-only; the component
|
|
100
|
+
* formats per brand on display (`[4,4,4,4]` default, `[4,6,5]` Amex, `[4,6,4]` Diners) and emits
|
|
101
|
+
* digits-only via `onChange`.
|
|
102
|
+
*/
|
|
103
|
+
export const CardInput = React.forwardRef<HTMLInputElement, CardInputProps>(
|
|
104
|
+
(
|
|
105
|
+
{
|
|
106
|
+
className,
|
|
107
|
+
inputClassName,
|
|
108
|
+
size,
|
|
109
|
+
value,
|
|
110
|
+
onChange,
|
|
111
|
+
onBrandChange,
|
|
112
|
+
brands: brandsProp,
|
|
113
|
+
showLeadingIcon = true,
|
|
114
|
+
showBrandBadge = true,
|
|
115
|
+
placeholder,
|
|
116
|
+
disabled,
|
|
117
|
+
...props
|
|
118
|
+
},
|
|
119
|
+
ref,
|
|
120
|
+
) => {
|
|
121
|
+
const t = useT()
|
|
122
|
+
const brands = brandsProp ?? CARD_BRANDS
|
|
123
|
+
const sanitizedValue = sanitize(value ?? '', DEFAULT_MAX_LENGTH)
|
|
124
|
+
const detected = detectBrand(sanitizedValue, brands)
|
|
125
|
+
const format = detected?.format ?? DEFAULT_FORMAT
|
|
126
|
+
const maxLength = detected?.maxLength ?? DEFAULT_MAX_LENGTH
|
|
127
|
+
const truncated = sanitizedValue.slice(0, maxLength)
|
|
128
|
+
const displayed = formatDigits(truncated, format)
|
|
129
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.cardInput.placeholder', '0000 0000 0000 0000')
|
|
130
|
+
|
|
131
|
+
const lastBrandRef = React.useRef<string | null>(null)
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
const next = detected?.id ?? null
|
|
134
|
+
if (next !== lastBrandRef.current) {
|
|
135
|
+
lastBrandRef.current = next
|
|
136
|
+
onBrandChange?.(detected)
|
|
137
|
+
}
|
|
138
|
+
}, [detected, onBrandChange])
|
|
139
|
+
|
|
140
|
+
const handleChange = React.useCallback(
|
|
141
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
142
|
+
const nextBrand = detectBrand(sanitize(event.target.value, DEFAULT_MAX_LENGTH), brands)
|
|
143
|
+
const nextMax = nextBrand?.maxLength ?? DEFAULT_MAX_LENGTH
|
|
144
|
+
const digits = sanitize(event.target.value, nextMax)
|
|
145
|
+
onChange(digits)
|
|
146
|
+
},
|
|
147
|
+
[brands, onChange],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className={cn(inputWrapperVariants({ size }), className)}
|
|
153
|
+
data-slot="card-input-wrapper"
|
|
154
|
+
data-card-brand={detected?.id ?? 'unknown'}
|
|
155
|
+
>
|
|
156
|
+
{showLeadingIcon ? (
|
|
157
|
+
<CreditCard className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
158
|
+
) : null}
|
|
159
|
+
<input
|
|
160
|
+
ref={ref}
|
|
161
|
+
type="text"
|
|
162
|
+
inputMode="numeric"
|
|
163
|
+
autoComplete="cc-number"
|
|
164
|
+
value={displayed}
|
|
165
|
+
onChange={handleChange}
|
|
166
|
+
placeholder={resolvedPlaceholder}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
className={cn(
|
|
169
|
+
inputElementVariants({ size }),
|
|
170
|
+
'tracking-[0.02em] tabular-nums',
|
|
171
|
+
inputClassName,
|
|
172
|
+
)}
|
|
173
|
+
{...props}
|
|
174
|
+
/>
|
|
175
|
+
{showBrandBadge && detected ? (
|
|
176
|
+
<span
|
|
177
|
+
className="flex shrink-0 items-center justify-center rounded-sm px-1.5 text-overline font-semibold uppercase tracking-wider tabular-nums leading-none select-none"
|
|
178
|
+
style={{
|
|
179
|
+
backgroundColor: detected.bg,
|
|
180
|
+
color: detected.fg ?? '#ffffff',
|
|
181
|
+
height: '24px',
|
|
182
|
+
minWidth: '32px',
|
|
183
|
+
}}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
>
|
|
186
|
+
{detected.label}
|
|
187
|
+
</span>
|
|
188
|
+
) : null}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
CardInput.displayName = 'CardInput'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Mail } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Input, type InputProps } from './input'
|
|
7
|
+
|
|
8
|
+
export type EmailInputProps = Omit<InputProps, 'leftIcon' | 'type'> & {
|
|
9
|
+
/** Show the leading Mail icon. Defaults to `true` per Figma `Text Input [1.1]` Email variant. */
|
|
10
|
+
showIcon?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Email input matching Figma `Text Input [1.1]` (node `266:5251`) **Email** variant β a thin
|
|
15
|
+
* `Input` wrapper that hardcodes `type="email"`, `inputMode="email"`, `autoComplete="email"`,
|
|
16
|
+
* and renders a leading `Mail` icon by default. Placeholder defaults to an auto-translated
|
|
17
|
+
* `ui.inputs.emailInput.placeholder`.
|
|
18
|
+
*/
|
|
19
|
+
export const EmailInput = React.forwardRef<HTMLInputElement, EmailInputProps>(
|
|
20
|
+
({ showIcon = true, placeholder, ...props }, ref) => {
|
|
21
|
+
const t = useT()
|
|
22
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.emailInput.placeholder', 'name@example.com')
|
|
23
|
+
return (
|
|
24
|
+
<Input
|
|
25
|
+
ref={ref}
|
|
26
|
+
type="email"
|
|
27
|
+
inputMode="email"
|
|
28
|
+
autoComplete="email"
|
|
29
|
+
placeholder={resolvedPlaceholder}
|
|
30
|
+
leftIcon={showIcon ? <Mail aria-hidden="true" /> : undefined}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
EmailInput.displayName = 'EmailInput'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Eye, EyeOff, Lock } from 'lucide-react'
|
|
5
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
9
|
+
|
|
10
|
+
export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &
|
|
11
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
12
|
+
/** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */
|
|
13
|
+
showLockIcon?: boolean
|
|
14
|
+
/** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */
|
|
15
|
+
revealable?: boolean
|
|
16
|
+
/** Optional controlled reveal state (otherwise managed internally). */
|
|
17
|
+
revealed?: boolean
|
|
18
|
+
/** Called when the reveal state changes. */
|
|
19
|
+
onRevealedChange?: (next: boolean) => void
|
|
20
|
+
/** Optional className on the wrapper. */
|
|
21
|
+
className?: string
|
|
22
|
+
/** Optional className on the inner `<input>`. */
|
|
23
|
+
inputClassName?: string
|
|
24
|
+
/** Translated aria-label for the reveal button when password is hidden. */
|
|
25
|
+
showLabel?: string
|
|
26
|
+
/** Translated aria-label for the reveal button when password is shown. */
|
|
27
|
+
hideLabel?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant β a
|
|
32
|
+
* trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `"password"`
|
|
33
|
+
* (default) and `"text"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).
|
|
34
|
+
*
|
|
35
|
+
* Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise
|
|
36
|
+
* the consumer owns the toggle state (useful for "show password" master toggle on login screens).
|
|
37
|
+
*/
|
|
38
|
+
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
39
|
+
(
|
|
40
|
+
{
|
|
41
|
+
className,
|
|
42
|
+
inputClassName,
|
|
43
|
+
size,
|
|
44
|
+
showLockIcon = true,
|
|
45
|
+
revealable = true,
|
|
46
|
+
revealed: revealedProp,
|
|
47
|
+
onRevealedChange,
|
|
48
|
+
showLabel,
|
|
49
|
+
hideLabel,
|
|
50
|
+
disabled,
|
|
51
|
+
...props
|
|
52
|
+
},
|
|
53
|
+
ref,
|
|
54
|
+
) => {
|
|
55
|
+
const t = useT()
|
|
56
|
+
const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')
|
|
57
|
+
const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')
|
|
58
|
+
const [internalRevealed, setInternalRevealed] = React.useState(false)
|
|
59
|
+
const isControlled = revealedProp !== undefined
|
|
60
|
+
const revealed = isControlled ? revealedProp! : internalRevealed
|
|
61
|
+
|
|
62
|
+
const toggleRevealed = React.useCallback(() => {
|
|
63
|
+
const next = !revealed
|
|
64
|
+
if (!isControlled) setInternalRevealed(next)
|
|
65
|
+
onRevealedChange?.(next)
|
|
66
|
+
}, [revealed, isControlled, onRevealedChange])
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn(inputWrapperVariants({ size }), className)} data-slot="password-input-wrapper">
|
|
70
|
+
{showLockIcon ? (
|
|
71
|
+
<Lock className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
72
|
+
) : null}
|
|
73
|
+
<input
|
|
74
|
+
ref={ref}
|
|
75
|
+
type={revealed ? 'text' : 'password'}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
className={cn(inputElementVariants({ size }), inputClassName)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
{revealable ? (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={toggleRevealed}
|
|
84
|
+
aria-label={revealed ? resolvedHideLabel : resolvedShowLabel}
|
|
85
|
+
aria-pressed={revealed}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
className="flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"
|
|
88
|
+
>
|
|
89
|
+
{revealed ? (
|
|
90
|
+
<EyeOff className="size-4" aria-hidden="true" />
|
|
91
|
+
) : (
|
|
92
|
+
<Eye className="size-4" aria-hidden="true" />
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
PasswordInput.displayName = 'PasswordInput'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Search, X } from 'lucide-react'
|
|
5
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
9
|
+
|
|
10
|
+
export type SearchInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type' | 'onChange'> &
|
|
11
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
12
|
+
/** Controlled value. */
|
|
13
|
+
value: string
|
|
14
|
+
/** Called on every keystroke with the new value. */
|
|
15
|
+
onChange: (next: string) => void
|
|
16
|
+
/**
|
|
17
|
+
* Called when the user presses the trailing Γ button.
|
|
18
|
+
* Defaults to `onChange('')` β pass an explicit handler to also reset adjacent state
|
|
19
|
+
* (e.g. cancel an in-flight request, reset paging).
|
|
20
|
+
*/
|
|
21
|
+
onClear?: () => void
|
|
22
|
+
/** Show the trailing Γ button when the value is non-empty. Defaults to `true`. */
|
|
23
|
+
clearable?: boolean
|
|
24
|
+
/** Optional className on the wrapper. */
|
|
25
|
+
className?: string
|
|
26
|
+
/** Optional className on the inner `<input>`. */
|
|
27
|
+
inputClassName?: string
|
|
28
|
+
/** Translated aria-label for the clear button. Defaults to `t('ui.inputs.searchInput.clear', 'Clear search')`. */
|
|
29
|
+
clearLabel?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Search input matching Figma `Text Input [1.1]` (node `266:5251`) **Search** variant β a leading
|
|
34
|
+
* `Search` icon, the text input, and an optional trailing `X` button to clear the field. Built on
|
|
35
|
+
* the shared `inputWrapperVariants` / `inputElementVariants` CVA so the visual contract stays in
|
|
36
|
+
* sync with the foundation `Input` primitive.
|
|
37
|
+
*
|
|
38
|
+
* The clear button is rendered as a proper `<button>` (not inside an `aria-hidden` decorative
|
|
39
|
+
* span), so it remains keyboard-focusable and screen-reader-accessible.
|
|
40
|
+
*/
|
|
41
|
+
export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
|
42
|
+
({ className, inputClassName, size, value, onChange, onClear, clearable = true, clearLabel, placeholder, disabled, ...props }, ref) => {
|
|
43
|
+
const t = useT()
|
|
44
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.searchInput.placeholder', 'Searchβ¦')
|
|
45
|
+
const resolvedClearLabel = clearLabel ?? t('ui.inputs.searchInput.clear', 'Clear search')
|
|
46
|
+
const showClear = clearable && value.length > 0 && !disabled
|
|
47
|
+
|
|
48
|
+
const handleClear = React.useCallback(() => {
|
|
49
|
+
if (onClear) onClear()
|
|
50
|
+
else onChange('')
|
|
51
|
+
}, [onChange, onClear])
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={cn(inputWrapperVariants({ size }), className)} data-slot="search-input-wrapper">
|
|
55
|
+
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
56
|
+
<input
|
|
57
|
+
ref={ref}
|
|
58
|
+
type="search"
|
|
59
|
+
inputMode="search"
|
|
60
|
+
autoComplete="off"
|
|
61
|
+
placeholder={resolvedPlaceholder}
|
|
62
|
+
value={value}
|
|
63
|
+
onChange={(event) => onChange(event.target.value)}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
className={cn(
|
|
66
|
+
inputElementVariants({ size }),
|
|
67
|
+
// Disable the native search clear button β we render our own.
|
|
68
|
+
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
|
|
69
|
+
inputClassName,
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
{showClear ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={handleClear}
|
|
77
|
+
aria-label={resolvedClearLabel}
|
|
78
|
+
className="flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
79
|
+
>
|
|
80
|
+
<X className="size-4" aria-hidden="true" />
|
|
81
|
+
</button>
|
|
82
|
+
) : null}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
SearchInput.displayName = 'SearchInput'
|
|
@@ -44,6 +44,46 @@ const SelectTrigger = React.forwardRef<
|
|
|
44
44
|
))
|
|
45
45
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Leading visual slot inside a `SelectTrigger` β render flags, avatars,
|
|
49
|
+
* provider logos, brand marks, or company icons before the `<SelectValue>`.
|
|
50
|
+
*
|
|
51
|
+
* Mirrors the Type variants in the Figma DS (Basic / Country / Avatar /
|
|
52
|
+
* Provider / Brand / Company β Figma `Select [1.1]`, node `270:1085`).
|
|
53
|
+
*
|
|
54
|
+
* Render inside `SelectTrigger`, BEFORE the `<SelectValue>`:
|
|
55
|
+
*
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <SelectTrigger>
|
|
58
|
+
* <SelectTriggerLeading><Avatar size="sm" label={user.name} /></SelectTriggerLeading>
|
|
59
|
+
* <SelectValue placeholder="Select user" />
|
|
60
|
+
* </SelectTrigger>
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Sizing follows the trigger: icons render `size-4`, images render `size-5`
|
|
64
|
+
* by default β pass your own classes on the inner element when you need a
|
|
65
|
+
* different visual size (e.g. country flags `h-3 w-4`).
|
|
66
|
+
*/
|
|
67
|
+
const SelectTriggerLeading = React.forwardRef<
|
|
68
|
+
HTMLSpanElement,
|
|
69
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
70
|
+
>(({ className, children, ...props }, ref) => (
|
|
71
|
+
<span
|
|
72
|
+
ref={ref}
|
|
73
|
+
data-slot="select-trigger-leading"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex shrink-0 items-center justify-center',
|
|
77
|
+
'[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-5 [&>img]:shrink-0',
|
|
78
|
+
className
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</span>
|
|
84
|
+
))
|
|
85
|
+
SelectTriggerLeading.displayName = 'SelectTriggerLeading'
|
|
86
|
+
|
|
47
87
|
const SelectScrollUpButton = React.forwardRef<
|
|
48
88
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
49
89
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
@@ -92,7 +132,7 @@ const SelectContent = React.forwardRef<
|
|
|
92
132
|
<SelectScrollUpButton />
|
|
93
133
|
<SelectPrimitive.Viewport
|
|
94
134
|
className={cn(
|
|
95
|
-
'p-
|
|
135
|
+
'p-2 max-h-[var(--radix-select-content-available-height)] overflow-y-auto',
|
|
96
136
|
position === 'popper' && 'w-full min-w-[var(--radix-select-trigger-width)]'
|
|
97
137
|
)}
|
|
98
138
|
>
|
|
@@ -110,7 +150,11 @@ const SelectLabel = React.forwardRef<
|
|
|
110
150
|
>(({ className, ...props }, ref) => (
|
|
111
151
|
<SelectPrimitive.Label
|
|
112
152
|
ref={ref}
|
|
113
|
-
className={cn(
|
|
153
|
+
className={cn(
|
|
154
|
+
'px-2 pt-2 pb-1 text-overline font-semibold uppercase tracking-wider text-muted-foreground/80',
|
|
155
|
+
'select-none',
|
|
156
|
+
className
|
|
157
|
+
)}
|
|
114
158
|
{...props}
|
|
115
159
|
/>
|
|
116
160
|
))
|
|
@@ -123,7 +167,7 @@ const SelectItem = React.forwardRef<
|
|
|
123
167
|
<SelectPrimitive.Item
|
|
124
168
|
ref={ref}
|
|
125
169
|
className={cn(
|
|
126
|
-
'relative flex w-full cursor-default select-none items-center gap-2 rounded-
|
|
170
|
+
'relative flex w-full cursor-default select-none items-center gap-2 rounded-md p-2 text-sm outline-none transition-colors',
|
|
127
171
|
'focus:bg-muted focus:text-foreground',
|
|
128
172
|
'data-[state=checked]:bg-muted/70 data-[state=checked]:text-foreground',
|
|
129
173
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
@@ -132,18 +176,59 @@ const SelectItem = React.forwardRef<
|
|
|
132
176
|
)}
|
|
133
177
|
{...props}
|
|
134
178
|
>
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</
|
|
179
|
+
<SelectPrimitive.ItemText asChild>
|
|
180
|
+
<span className="flex min-w-0 flex-1 items-center gap-2 truncate">
|
|
181
|
+
{children}
|
|
182
|
+
</span>
|
|
183
|
+
</SelectPrimitive.ItemText>
|
|
184
|
+
<SelectPrimitive.ItemIndicator className="ml-auto flex shrink-0 items-center justify-center">
|
|
185
|
+
<Check className="size-4 text-foreground" aria-hidden="true" />
|
|
186
|
+
</SelectPrimitive.ItemIndicator>
|
|
143
187
|
</SelectPrimitive.Item>
|
|
144
188
|
))
|
|
145
189
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
146
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Leading visual slot inside a `SelectItem` β mirror of `SelectTriggerLeading`
|
|
193
|
+
* for dropdown rows. Compose with the DS `Avatar`, country flag images, or
|
|
194
|
+
* `lucide-react` icons.
|
|
195
|
+
*
|
|
196
|
+
* Render inside the `<SelectItem>` children, before the row label:
|
|
197
|
+
*
|
|
198
|
+
* ```tsx
|
|
199
|
+
* <SelectItem value="us">
|
|
200
|
+
* <SelectItemLeading><span className="h-3 w-4 rounded-sm overflow-hidden">πΊπΈ</span></SelectItemLeading>
|
|
201
|
+
* United States
|
|
202
|
+
* </SelectItem>
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* NOTE: `SelectPrimitive.ItemText` only reads the text node siblings for the
|
|
206
|
+
* trigger preview β wrap the leading slot OUTSIDE `ItemText` (which this
|
|
207
|
+
* primitive does by default, since `SelectItem`'s `children` are placed inside
|
|
208
|
+
* `<SelectPrimitive.ItemText>` only via the inner `<span className="flex-1
|
|
209
|
+
* truncate">`). Place the `SelectItemLeading` alongside the row text and the
|
|
210
|
+
* label will read correctly in the trigger preview.
|
|
211
|
+
*/
|
|
212
|
+
const SelectItemLeading = React.forwardRef<
|
|
213
|
+
HTMLSpanElement,
|
|
214
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
215
|
+
>(({ className, children, ...props }, ref) => (
|
|
216
|
+
<span
|
|
217
|
+
ref={ref}
|
|
218
|
+
data-slot="select-item-leading"
|
|
219
|
+
aria-hidden="true"
|
|
220
|
+
className={cn(
|
|
221
|
+
'flex shrink-0 items-center justify-center',
|
|
222
|
+
'[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-5 [&>img]:shrink-0',
|
|
223
|
+
className
|
|
224
|
+
)}
|
|
225
|
+
{...props}
|
|
226
|
+
>
|
|
227
|
+
{children}
|
|
228
|
+
</span>
|
|
229
|
+
))
|
|
230
|
+
SelectItemLeading.displayName = 'SelectItemLeading'
|
|
231
|
+
|
|
147
232
|
const SelectSeparator = React.forwardRef<
|
|
148
233
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
149
234
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
@@ -191,9 +276,11 @@ export {
|
|
|
191
276
|
SelectGroup,
|
|
192
277
|
SelectValue,
|
|
193
278
|
SelectTrigger,
|
|
279
|
+
SelectTriggerLeading,
|
|
194
280
|
SelectContent,
|
|
195
281
|
SelectLabel,
|
|
196
282
|
SelectItem,
|
|
283
|
+
SelectItemLeading,
|
|
197
284
|
SelectSeparator,
|
|
198
285
|
SelectScrollUpButton,
|
|
199
286
|
SelectScrollDownButton,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
8
|
+
|
|
9
|
+
export type WebsiteInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &
|
|
10
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
11
|
+
/** Protocol shown in the left prefix box. Defaults to `'https://'` per Figma. */
|
|
12
|
+
prefix?: string
|
|
13
|
+
/** Hide the prefix box entirely (consumer wants a bare URL input). */
|
|
14
|
+
showPrefix?: boolean
|
|
15
|
+
/** Optional className on the wrapper. */
|
|
16
|
+
className?: string
|
|
17
|
+
/** Optional className on the inner `<input>`. */
|
|
18
|
+
inputClassName?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Website input matching Figma `Text Input [1.1]` (node `266:5251`) **Website** variant β a left
|
|
23
|
+
* prefix box with the protocol text (default `'https://'`), a vertical divider, then the URL text
|
|
24
|
+
* input. Built on the shared `inputWrapperVariants` / `inputElementVariants` CVA so the visual
|
|
25
|
+
* contract matches the foundation `Input` primitive.
|
|
26
|
+
*
|
|
27
|
+
* The prefix is **display-only** β the inner `<input>` value contains ONLY the host/path portion
|
|
28
|
+
* (e.g. `'www.example.com/path'`). Compose the full URL at the consumer boundary if needed.
|
|
29
|
+
*/
|
|
30
|
+
export const WebsiteInput = React.forwardRef<HTMLInputElement, WebsiteInputProps>(
|
|
31
|
+
({ className, inputClassName, size, prefix = 'https://', showPrefix = true, placeholder, disabled, ...props }, ref) => {
|
|
32
|
+
const t = useT()
|
|
33
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.websiteInput.placeholder', 'www.example.com')
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
inputWrapperVariants({ size }),
|
|
38
|
+
// Override default px so prefix box can sit flush left.
|
|
39
|
+
'px-0 overflow-hidden',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
data-slot="website-input-wrapper"
|
|
43
|
+
>
|
|
44
|
+
{showPrefix ? (
|
|
45
|
+
<>
|
|
46
|
+
<span
|
|
47
|
+
className="flex shrink-0 items-center self-stretch border-r border-input px-3 text-sm text-muted-foreground select-none"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
>
|
|
50
|
+
{prefix}
|
|
51
|
+
</span>
|
|
52
|
+
</>
|
|
53
|
+
) : null}
|
|
54
|
+
<input
|
|
55
|
+
ref={ref}
|
|
56
|
+
type="url"
|
|
57
|
+
inputMode="url"
|
|
58
|
+
autoComplete="url"
|
|
59
|
+
placeholder={resolvedPlaceholder}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
className={cn(
|
|
62
|
+
inputElementVariants({ size }),
|
|
63
|
+
'px-3',
|
|
64
|
+
inputClassName,
|
|
65
|
+
)}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
WebsiteInput.displayName = 'WebsiteInput'
|