@open-mercato/ui 0.6.1-develop.3278.1.a4d26475e4 β†’ 0.6.1-develop.3287.1.450f4ffb56

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 (72) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/FilterBar.js +11 -14
  8. package/dist/backend/FilterBar.js.map +2 -2
  9. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  10. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  11. package/dist/backend/inputs/ComboboxInput.js +11 -7
  12. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  13. package/dist/backend/inputs/EventPatternInput.js +5 -2
  14. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  15. package/dist/backend/inputs/EventSelect.js +10 -4
  16. package/dist/backend/inputs/EventSelect.js.map +2 -2
  17. package/dist/backend/inputs/LookupSelect.js +51 -47
  18. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  19. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  20. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  21. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  22. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  23. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  24. package/dist/backend/utils/serverErrors.js +6 -2
  25. package/dist/backend/utils/serverErrors.js.map +2 -2
  26. package/dist/portal/utils/nav.js +17 -0
  27. package/dist/portal/utils/nav.js.map +2 -2
  28. package/dist/primitives/amount-input.js +128 -0
  29. package/dist/primitives/amount-input.js.map +7 -0
  30. package/dist/primitives/button-input.js +47 -0
  31. package/dist/primitives/button-input.js.map +7 -0
  32. package/dist/primitives/card-input.js +133 -0
  33. package/dist/primitives/card-input.js.map +7 -0
  34. package/dist/primitives/email-input.js +29 -0
  35. package/dist/primitives/email-input.js.map +7 -0
  36. package/dist/primitives/password-input.js +64 -0
  37. package/dist/primitives/password-input.js.map +7 -0
  38. package/dist/primitives/search-input.js +57 -0
  39. package/dist/primitives/search-input.js.map +7 -0
  40. package/dist/primitives/select.js +43 -5
  41. package/dist/primitives/select.js.map +2 -2
  42. package/dist/primitives/website-input.js +56 -0
  43. package/dist/primitives/website-input.js.map +7 -0
  44. package/package.json +3 -3
  45. package/src/backend/AppShell.tsx +7 -30
  46. package/src/backend/CrudForm.tsx +17 -0
  47. package/src/backend/FilterBar.tsx +5 -6
  48. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  49. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  50. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  51. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  52. package/src/backend/inputs/EventSelect.tsx +13 -4
  53. package/src/backend/inputs/LookupSelect.tsx +75 -53
  54. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  55. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  56. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  57. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  58. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  59. package/src/backend/utils/serverErrors.ts +15 -8
  60. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  61. package/src/portal/utils/nav.ts +23 -1
  62. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  63. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  64. package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -0
  65. package/src/primitives/amount-input.tsx +184 -0
  66. package/src/primitives/button-input.tsx +68 -0
  67. package/src/primitives/card-input.tsx +193 -0
  68. package/src/primitives/email-input.tsx +36 -0
  69. package/src/primitives/password-input.tsx +100 -0
  70. package/src/primitives/search-input.tsx +87 -0
  71. package/src/primitives/select.tsx +98 -11
  72. 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-1 max-h-[var(--radix-select-content-available-height)] overflow-y-auto',
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('px-2 py-1.5 text-overline uppercase text-muted-foreground', className)}
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-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors',
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
- <span className="flex-1 truncate">
136
- <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
137
- </span>
138
- <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
139
- <SelectPrimitive.ItemIndicator>
140
- <Check className="size-4 text-foreground" aria-hidden="true" />
141
- </SelectPrimitive.ItemIndicator>
142
- </span>
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'