@webority-technologies/mobile 0.0.20 → 0.0.21

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.
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.resolveVariantColors = exports.resolveFieldTextStyle = exports.resolveFieldSize = exports.default = exports.FieldBase = void 0;
6
+ exports.resolveVariantColors = exports.resolveFieldTextStyle = exports.resolveFieldSize = exports.pickRepresentativeBorderColor = exports.default = exports.FieldBase = void 0;
7
7
  var _react = _interopRequireWildcard(require("react"));
8
8
  var _reactNative = require("react-native");
9
9
  var _index = require("../../theme/index.js");
@@ -33,6 +33,66 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
33
33
  * - Shake-on-error or any cross-field animation.
34
34
  */
35
35
 
36
+ const SIDES = ['top', 'right', 'bottom', 'left'];
37
+
38
+ /** Map a single colour string to all four sides (shorthand expansion). */
39
+ const expandColorSides = value => {
40
+ if (typeof value === 'string') {
41
+ return {
42
+ top: value,
43
+ right: value,
44
+ bottom: value,
45
+ left: value
46
+ };
47
+ }
48
+ // Object form: any side the consumer omitted falls through to 'transparent'.
49
+ // Rationale: if a consumer says `{ bottom: '#000' }`, they want only the
50
+ // bottom drawn — the other sides should be invisible, not inherit some
51
+ // unrelated colour.
52
+ return {
53
+ top: value.top ?? 'transparent',
54
+ right: value.right ?? 'transparent',
55
+ bottom: value.bottom ?? 'transparent',
56
+ left: value.left ?? 'transparent'
57
+ };
58
+ };
59
+
60
+ /** Map a single width number to all four sides (shorthand expansion). */
61
+ const expandWidthSides = value => {
62
+ if (typeof value === 'number') {
63
+ return {
64
+ top: value,
65
+ right: value,
66
+ bottom: value,
67
+ left: value
68
+ };
69
+ }
70
+ return {
71
+ top: value.top ?? 0,
72
+ right: value.right ?? 0,
73
+ bottom: value.bottom ?? 0,
74
+ left: value.left ?? 0
75
+ };
76
+ };
77
+
78
+ /** Are all four sides of a per-side record equal? Used to collapse to shorthand. */
79
+ const allSidesEqual = sides => sides.top === sides.right && sides.right === sides.bottom && sides.bottom === sides.left;
80
+
81
+ /**
82
+ * Pick the most "representative" side colour from a 4-side record. Used by
83
+ * consumers (Input's selectionColor, focus rings, etc.) that need a single
84
+ * colour to mirror the visible focus indicator. Priority: bottom → top →
85
+ * left → right, skipping `'transparent'`. The bottom-first order means
86
+ * underline variants pick their underline colour automatically.
87
+ */
88
+ const pickRepresentativeBorderColor = sides => {
89
+ for (const side of ['bottom', 'top', 'left', 'right']) {
90
+ const c = sides[side];
91
+ if (c && c !== 'transparent') return c;
92
+ }
93
+ return sides.bottom;
94
+ };
95
+
36
96
  /**
37
97
  * Resolved text styling for the editable / displayed content inside a field.
38
98
  * Single source of truth so every field component (Input, NumberInput,
@@ -41,7 +101,7 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
41
101
  * same place. OTPInput intentionally overrides this with its own semibold
42
102
  * display weight.
43
103
  */
44
-
104
+ exports.pickRepresentativeBorderColor = pickRepresentativeBorderColor;
45
105
  /**
46
106
  * Resolve the canonical text style chunk for a field's value. Reads from
47
107
  * `theme.components.field.{textColor,disabledTextColor,placeholderColor,fontWeight}`
@@ -122,31 +182,70 @@ const DEFAULT_SIZES = {
122
182
 
123
183
  /**
124
184
  * Strict, fully-resolved colour set used internally. Mirrors `FieldVariantTokens`
125
- * but every field is non-optional every state has a concrete colour by the
126
- * time the box renders, so downstream code can rely on string-typed fills.
185
+ * but every border colour / width is expanded to a 4-side record by the time
186
+ * the box renders, so the animation pipeline can treat per-side and shorthand
187
+ * the same way. `borderFocusedRepresentative` is a single colour pulled from
188
+ * the focused sides — exposed for consumers (Input's selectionColor) that
189
+ * need to mirror the visible focus indicator without re-resolving per side.
127
190
  */
128
191
 
129
192
  /**
130
193
  * Resolve the full set of state-aware fill + border colours for a given
131
- * variant. Order: explicit override → theme token → library default.
194
+ * variant. Order: explicit override → theme token → library default. Border
195
+ * colour + width are normalized to per-side records here; callers that need
196
+ * a single colour read `borderFocusedRepresentative`.
132
197
  */
133
198
  const resolveVariantColors = (theme, variant, override) => {
134
199
  const tokenSet = theme.components.field?.[variant];
135
- const isOutlined = variant === 'outlined';
136
- const idleEmpty = isOutlined ? theme.colors.background.primary : theme.colors.background.secondary;
137
- const borderIdle = isOutlined ? theme.colors.border.primary : 'transparent';
138
- const borderWidth = isOutlined ? theme.colors.border.width : 0;
200
+
201
+ // Variant-specific defaults for resting fill + border treatment. `outlined`
202
+ // draws all four sides; `filled` draws nothing (fill-only); `underline`
203
+ // draws only the bottom edge Material-style single-line inputs.
204
+ let idleEmpty;
205
+ let borderIdleDefault;
206
+ let borderWidthDefault;
207
+ if (variant === 'outlined') {
208
+ idleEmpty = theme.colors.background.primary;
209
+ borderIdleDefault = theme.colors.border.primary;
210
+ borderWidthDefault = theme.colors.border.width;
211
+ } else if (variant === 'underline') {
212
+ idleEmpty = 'transparent';
213
+ borderIdleDefault = {
214
+ bottom: theme.colors.border.primary
215
+ };
216
+ borderWidthDefault = {
217
+ bottom: theme.colors.border.width
218
+ };
219
+ } else {
220
+ idleEmpty = theme.colors.background.secondary;
221
+ borderIdleDefault = 'transparent';
222
+ borderWidthDefault = 0;
223
+ }
224
+
225
+ // For focused/error/disabled we expand using the same per-side shape as
226
+ // idle. If `borderIdle` ended up bottom-only (underline), an unset
227
+ // `borderFocused` defaults to `{ bottom: colors.border.focus }` so the
228
+ // focus indicator stays on the same edge. Same logic for error.
229
+ const wrapToIdleShape = colour => typeof borderIdleDefault === 'string' ? colour : {
230
+ bottom: colour
231
+ };
232
+ const borderIdle = expandColorSides(override?.borderIdle ?? tokenSet?.borderIdle ?? borderIdleDefault);
233
+ const borderFocused = expandColorSides(override?.borderFocused ?? tokenSet?.borderFocused ?? wrapToIdleShape(theme.colors.border.focus));
234
+ const borderError = expandColorSides(override?.borderError ?? tokenSet?.borderError ?? wrapToIdleShape(theme.colors.border.error));
235
+ const borderDisabled = expandColorSides(override?.borderDisabled ?? tokenSet?.borderDisabled ?? borderIdleDefault);
236
+ const borderWidth = expandWidthSides(override?.borderWidth ?? tokenSet?.borderWidth ?? borderWidthDefault);
139
237
  return {
140
238
  backgroundIdleEmpty: override?.backgroundIdleEmpty ?? tokenSet?.backgroundIdleEmpty ?? idleEmpty,
141
239
  backgroundIdleFilled: override?.backgroundIdleFilled ?? tokenSet?.backgroundIdleFilled ?? override?.backgroundIdleEmpty ?? tokenSet?.backgroundIdleEmpty ?? idleEmpty,
142
240
  backgroundFocused: override?.backgroundFocused ?? tokenSet?.backgroundFocused ?? undefined,
143
241
  backgroundError: override?.backgroundError ?? tokenSet?.backgroundError ?? undefined,
144
242
  backgroundDisabled: override?.backgroundDisabled ?? tokenSet?.backgroundDisabled ?? theme.colors.surface.disabled,
145
- borderIdle: override?.borderIdle ?? tokenSet?.borderIdle ?? borderIdle,
146
- borderFocused: override?.borderFocused ?? tokenSet?.borderFocused ?? theme.colors.border.focus,
147
- borderError: override?.borderError ?? tokenSet?.borderError ?? theme.colors.border.error,
148
- borderDisabled: override?.borderDisabled ?? tokenSet?.borderDisabled ?? borderIdle,
149
- borderWidth: override?.borderWidth ?? tokenSet?.borderWidth ?? borderWidth
243
+ borderIdle,
244
+ borderFocused,
245
+ borderError,
246
+ borderDisabled,
247
+ borderWidth,
248
+ borderFocusedRepresentative: pickRepresentativeBorderColor(borderFocused)
150
249
  };
151
250
  };
152
251
  exports.resolveVariantColors = resolveVariantColors;
@@ -169,7 +268,15 @@ const FieldBase = props => {
169
268
  paddingHorizontal: paddingHorizontalProp,
170
269
  paddingVertical: paddingVerticalProp,
171
270
  borderRadius: borderRadiusProp,
271
+ borderTopLeftRadius: borderTopLeftRadiusProp,
272
+ borderTopRightRadius: borderTopRightRadiusProp,
273
+ borderBottomLeftRadius: borderBottomLeftRadiusProp,
274
+ borderBottomRightRadius: borderBottomRightRadiusProp,
172
275
  borderWidth: borderWidthProp,
276
+ borderTopWidth: borderTopWidthProp,
277
+ borderRightWidth: borderRightWidthProp,
278
+ borderBottomWidth: borderBottomWidthProp,
279
+ borderLeftWidth: borderLeftWidthProp,
173
280
  gap: gapProp,
174
281
  fillOverrides,
175
282
  style,
@@ -208,13 +315,6 @@ const FieldBase = props => {
208
315
  const idleFill = filled ? colors.backgroundIdleFilled : colors.backgroundIdleEmpty;
209
316
  const focusedFill = colors.backgroundFocused ?? idleFill;
210
317
  const errorFill = colors.backgroundError ?? idleFill;
211
- const animatedBorderColor = disabled ? colors.borderDisabled : error ? errorAnim.interpolate({
212
- inputRange: [0, 1],
213
- outputRange: [colors.borderIdle, colors.borderError]
214
- }) : focusAnim.interpolate({
215
- inputRange: [0, 1],
216
- outputRange: [colors.borderIdle, colors.borderFocused]
217
- });
218
318
  const animatedBackgroundColor = disabled ? colors.backgroundDisabled : error ? errorAnim.interpolate({
219
319
  inputRange: [0, 1],
220
320
  outputRange: [idleFill, errorFill]
@@ -222,17 +322,105 @@ const FieldBase = props => {
222
322
  inputRange: [0, 1],
223
323
  outputRange: [idleFill, focusedFill]
224
324
  });
325
+
326
+ // Per-side border colour. For each side we resolve the "from" (idle) and
327
+ // "to" (focused or error, depending on state) colour, then drive a single
328
+ // animated interpolation. When all four sides share the same from+to pair
329
+ // we collapse to one `borderColor` key — the common case stays cheap.
330
+ const activeColors = error ? colors.borderError : colors.borderFocused;
331
+ const activeAnim = error ? errorAnim : focusAnim;
332
+ const sideFrom = disabled ? colors.borderDisabled : colors.borderIdle;
333
+ const sideTo = disabled ? colors.borderDisabled : activeColors;
334
+
335
+ // Per-side widths: longhand prop wins over shorthand prop wins over
336
+ // resolved variant token. Each side independently.
337
+ const propWidth = {
338
+ top: borderTopWidthProp,
339
+ right: borderRightWidthProp,
340
+ bottom: borderBottomWidthProp,
341
+ left: borderLeftWidthProp
342
+ };
343
+ const resolvedWidths = {
344
+ top: propWidth.top ?? borderWidthProp ?? colors.borderWidth.top,
345
+ right: propWidth.right ?? borderWidthProp ?? colors.borderWidth.right,
346
+ bottom: propWidth.bottom ?? borderWidthProp ?? colors.borderWidth.bottom,
347
+ left: propWidth.left ?? borderWidthProp ?? colors.borderWidth.left
348
+ };
225
349
  const boxStyle = {
226
350
  minHeight: height ?? minHeightProp ?? sizeTokens.minHeight,
227
351
  maxHeight,
228
352
  paddingHorizontal: paddingHorizontalProp ?? sizeTokens.paddingHorizontal,
229
353
  paddingVertical: paddingVerticalProp ?? sizeTokens.paddingVertical,
230
- borderRadius: borderRadiusProp ?? sizeTokens.borderRadius,
231
- borderWidth: borderWidthProp ?? colors.borderWidth,
232
- borderColor: animatedBorderColor,
233
354
  backgroundColor: animatedBackgroundColor,
234
355
  columnGap: gapProp ?? theme.spacing.sm
235
356
  };
357
+
358
+ // Radius: per-corner prop wins over shorthand prop wins over size token.
359
+ // Collapse to single `borderRadius` when all four corners match.
360
+ const radiusShorthand = borderRadiusProp ?? sizeTokens.borderRadius;
361
+ const corners = {
362
+ tl: borderTopLeftRadiusProp ?? radiusShorthand,
363
+ tr: borderTopRightRadiusProp ?? radiusShorthand,
364
+ bl: borderBottomLeftRadiusProp ?? radiusShorthand,
365
+ br: borderBottomRightRadiusProp ?? radiusShorthand
366
+ };
367
+ if (corners.tl === corners.tr && corners.tr === corners.bl && corners.bl === corners.br) {
368
+ boxStyle.borderRadius = corners.tl;
369
+ } else {
370
+ boxStyle.borderTopLeftRadius = corners.tl;
371
+ boxStyle.borderTopRightRadius = corners.tr;
372
+ boxStyle.borderBottomLeftRadius = corners.bl;
373
+ boxStyle.borderBottomRightRadius = corners.br;
374
+ }
375
+
376
+ // Width: collapse to single `borderWidth` when all four sides match.
377
+ if (resolvedWidths.top === resolvedWidths.right && resolvedWidths.right === resolvedWidths.bottom && resolvedWidths.bottom === resolvedWidths.left) {
378
+ boxStyle.borderWidth = resolvedWidths.top;
379
+ } else {
380
+ boxStyle.borderTopWidth = resolvedWidths.top;
381
+ boxStyle.borderRightWidth = resolvedWidths.right;
382
+ boxStyle.borderBottomWidth = resolvedWidths.bottom;
383
+ boxStyle.borderLeftWidth = resolvedWidths.left;
384
+ }
385
+
386
+ // Colour: collapse to single `borderColor` only when both endpoints are
387
+ // identical across all sides. Otherwise emit four `borderXColor`
388
+ // interpolations. Disabled state is static (no animation needed).
389
+ const fromAllEqual = allSidesEqual(sideFrom);
390
+ const toAllEqual = allSidesEqual(sideTo);
391
+ if (disabled) {
392
+ if (fromAllEqual) {
393
+ boxStyle.borderColor = sideFrom.top;
394
+ } else {
395
+ boxStyle.borderTopColor = sideFrom.top;
396
+ boxStyle.borderRightColor = sideFrom.right;
397
+ boxStyle.borderBottomColor = sideFrom.bottom;
398
+ boxStyle.borderLeftColor = sideFrom.left;
399
+ }
400
+ } else if (fromAllEqual && toAllEqual && sideFrom.top === sideFrom.right && sideTo.top === sideTo.right) {
401
+ // Common case: shorthand on both ends → single interpolation.
402
+ boxStyle.borderColor = activeAnim.interpolate({
403
+ inputRange: [0, 1],
404
+ outputRange: [sideFrom.top, sideTo.top]
405
+ });
406
+ } else {
407
+ boxStyle.borderTopColor = activeAnim.interpolate({
408
+ inputRange: [0, 1],
409
+ outputRange: [sideFrom.top, sideTo.top]
410
+ });
411
+ boxStyle.borderRightColor = activeAnim.interpolate({
412
+ inputRange: [0, 1],
413
+ outputRange: [sideFrom.right, sideTo.right]
414
+ });
415
+ boxStyle.borderBottomColor = activeAnim.interpolate({
416
+ inputRange: [0, 1],
417
+ outputRange: [sideFrom.bottom, sideTo.bottom]
418
+ });
419
+ boxStyle.borderLeftColor = activeAnim.interpolate({
420
+ inputRange: [0, 1],
421
+ outputRange: [sideFrom.left, sideTo.left]
422
+ });
423
+ }
236
424
  if (width !== undefined) boxStyle.width = width;
237
425
  if (height !== undefined) boxStyle.height = height;
238
426
  const a11yState = {
@@ -168,7 +168,7 @@ const Input = exports.Input = /*#__PURE__*/(0, _react.forwardRef)((props, ref) =
168
168
  // Selection / caret / handle colour walks the same resolution chain as the
169
169
  // focused border so brands that override `components.field.<variant>.borderFocused`
170
170
  // get a matching selection tint without also having to update root `border.focus`.
171
- const selectionColor = (0, _react.useMemo)(() => (0, _FieldBase.resolveVariantColors)(theme, variant, fillOverrides).borderFocused, [theme, variant, fillOverrides]);
171
+ const selectionColor = (0, _react.useMemo)(() => (0, _FieldBase.resolveVariantColors)(theme, variant, fillOverrides).borderFocusedRepresentative, [theme, variant, fillOverrides]);
172
172
  const borderRadiusOverride = borderRadiusProp ?? theme.components.input?.borderRadius;
173
173
 
174
174
  // Floating label styles
@@ -40,7 +40,13 @@ const SearchBar = exports.SearchBar = /*#__PURE__*/(0, _react.forwardRef)((props
40
40
  // field-family token (`components.field.defaultVariant`) → library default.
41
41
  // Library default stays `'filled'` so SearchBar reads as the traditional
42
42
  // pill-shaped, borderless search box when no theme opinion is expressed.
43
- const variant = variantProp ?? theme.components.searchBar?.defaultVariant ?? theme.components.field?.defaultVariant ?? 'filled';
43
+ // SearchBar deliberately only supports 'filled' and 'outlined' an
44
+ // underline-only pill makes no visual sense. If the shared field default is
45
+ // 'underline', fall through to 'filled' rather than carry an unsupported
46
+ // value down to the variant token lookup.
47
+ const sharedDefault = theme.components.field?.defaultVariant;
48
+ const sharedFallback = sharedDefault === 'outlined' || sharedDefault === 'filled' ? sharedDefault : 'filled';
49
+ const variant = variantProp ?? theme.components.searchBar?.defaultVariant ?? sharedFallback;
44
50
  const fieldTokens = (0, _FieldBase.resolveFieldSize)(theme, size);
45
51
  const sizeStyles = {
46
52
  ...fieldTokens,
@@ -184,7 +190,7 @@ const SearchBar = exports.SearchBar = /*#__PURE__*/(0, _react.forwardRef)((props
184
190
  autoFocus: autoFocus,
185
191
  editable: !disabled,
186
192
  returnKeyType: "search",
187
- selectionColor: (0, _FieldBase.resolveVariantColors)(theme, variant).borderFocused,
193
+ selectionColor: (0, _FieldBase.resolveVariantColors)(theme, variant).borderFocusedRepresentative,
188
194
  accessibilityLabel: accessibilityLabel ?? placeholder,
189
195
  accessibilityState: {
190
196
  disabled
@@ -326,7 +326,7 @@ const Select = exports.Select = /*#__PURE__*/(0, _react.forwardRef)((props, ref)
326
326
  onChangeText: setQuery,
327
327
  placeholder: "Search\u2026",
328
328
  placeholderTextColor: theme.colors.text.tertiary,
329
- selectionColor: (0, _FieldBase.resolveVariantColors)(theme, theme.components.field?.defaultVariant ?? 'outlined').borderFocused,
329
+ selectionColor: (0, _FieldBase.resolveVariantColors)(theme, theme.components.field?.defaultVariant ?? 'outlined').borderFocusedRepresentative,
330
330
  accessibilityLabel: "Search options",
331
331
  style: [styles.searchInput, {
332
332
  color: theme.colors.text.primary,
@@ -27,6 +27,66 @@
27
27
  import React, { useEffect, useMemo, useRef } from 'react';
28
28
  import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
29
29
  import { useTheme, createAnimatedValue, fontFor } from "../../theme/index.js";
30
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
31
+ const SIDES = ['top', 'right', 'bottom', 'left'];
32
+
33
+ /** Map a single colour string to all four sides (shorthand expansion). */
34
+ const expandColorSides = value => {
35
+ if (typeof value === 'string') {
36
+ return {
37
+ top: value,
38
+ right: value,
39
+ bottom: value,
40
+ left: value
41
+ };
42
+ }
43
+ // Object form: any side the consumer omitted falls through to 'transparent'.
44
+ // Rationale: if a consumer says `{ bottom: '#000' }`, they want only the
45
+ // bottom drawn — the other sides should be invisible, not inherit some
46
+ // unrelated colour.
47
+ return {
48
+ top: value.top ?? 'transparent',
49
+ right: value.right ?? 'transparent',
50
+ bottom: value.bottom ?? 'transparent',
51
+ left: value.left ?? 'transparent'
52
+ };
53
+ };
54
+
55
+ /** Map a single width number to all four sides (shorthand expansion). */
56
+ const expandWidthSides = value => {
57
+ if (typeof value === 'number') {
58
+ return {
59
+ top: value,
60
+ right: value,
61
+ bottom: value,
62
+ left: value
63
+ };
64
+ }
65
+ return {
66
+ top: value.top ?? 0,
67
+ right: value.right ?? 0,
68
+ bottom: value.bottom ?? 0,
69
+ left: value.left ?? 0
70
+ };
71
+ };
72
+
73
+ /** Are all four sides of a per-side record equal? Used to collapse to shorthand. */
74
+ const allSidesEqual = sides => sides.top === sides.right && sides.right === sides.bottom && sides.bottom === sides.left;
75
+
76
+ /**
77
+ * Pick the most "representative" side colour from a 4-side record. Used by
78
+ * consumers (Input's selectionColor, focus rings, etc.) that need a single
79
+ * colour to mirror the visible focus indicator. Priority: bottom → top →
80
+ * left → right, skipping `'transparent'`. The bottom-first order means
81
+ * underline variants pick their underline colour automatically.
82
+ */
83
+ export const pickRepresentativeBorderColor = sides => {
84
+ for (const side of ['bottom', 'top', 'left', 'right']) {
85
+ const c = sides[side];
86
+ if (c && c !== 'transparent') return c;
87
+ }
88
+ return sides.bottom;
89
+ };
30
90
 
31
91
  /**
32
92
  * Resolved text styling for the editable / displayed content inside a field.
@@ -36,7 +96,7 @@ import { useTheme, createAnimatedValue, fontFor } from "../../theme/index.js";
36
96
  * same place. OTPInput intentionally overrides this with its own semibold
37
97
  * display weight.
38
98
  */
39
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
99
+
40
100
  /**
41
101
  * Resolve the canonical text style chunk for a field's value. Reads from
42
102
  * `theme.components.field.{textColor,disabledTextColor,placeholderColor,fontWeight}`
@@ -115,31 +175,70 @@ const DEFAULT_SIZES = {
115
175
 
116
176
  /**
117
177
  * Strict, fully-resolved colour set used internally. Mirrors `FieldVariantTokens`
118
- * but every field is non-optional every state has a concrete colour by the
119
- * time the box renders, so downstream code can rely on string-typed fills.
178
+ * but every border colour / width is expanded to a 4-side record by the time
179
+ * the box renders, so the animation pipeline can treat per-side and shorthand
180
+ * the same way. `borderFocusedRepresentative` is a single colour pulled from
181
+ * the focused sides — exposed for consumers (Input's selectionColor) that
182
+ * need to mirror the visible focus indicator without re-resolving per side.
120
183
  */
121
184
 
122
185
  /**
123
186
  * Resolve the full set of state-aware fill + border colours for a given
124
- * variant. Order: explicit override → theme token → library default.
187
+ * variant. Order: explicit override → theme token → library default. Border
188
+ * colour + width are normalized to per-side records here; callers that need
189
+ * a single colour read `borderFocusedRepresentative`.
125
190
  */
126
191
  export const resolveVariantColors = (theme, variant, override) => {
127
192
  const tokenSet = theme.components.field?.[variant];
128
- const isOutlined = variant === 'outlined';
129
- const idleEmpty = isOutlined ? theme.colors.background.primary : theme.colors.background.secondary;
130
- const borderIdle = isOutlined ? theme.colors.border.primary : 'transparent';
131
- const borderWidth = isOutlined ? theme.colors.border.width : 0;
193
+
194
+ // Variant-specific defaults for resting fill + border treatment. `outlined`
195
+ // draws all four sides; `filled` draws nothing (fill-only); `underline`
196
+ // draws only the bottom edge Material-style single-line inputs.
197
+ let idleEmpty;
198
+ let borderIdleDefault;
199
+ let borderWidthDefault;
200
+ if (variant === 'outlined') {
201
+ idleEmpty = theme.colors.background.primary;
202
+ borderIdleDefault = theme.colors.border.primary;
203
+ borderWidthDefault = theme.colors.border.width;
204
+ } else if (variant === 'underline') {
205
+ idleEmpty = 'transparent';
206
+ borderIdleDefault = {
207
+ bottom: theme.colors.border.primary
208
+ };
209
+ borderWidthDefault = {
210
+ bottom: theme.colors.border.width
211
+ };
212
+ } else {
213
+ idleEmpty = theme.colors.background.secondary;
214
+ borderIdleDefault = 'transparent';
215
+ borderWidthDefault = 0;
216
+ }
217
+
218
+ // For focused/error/disabled we expand using the same per-side shape as
219
+ // idle. If `borderIdle` ended up bottom-only (underline), an unset
220
+ // `borderFocused` defaults to `{ bottom: colors.border.focus }` so the
221
+ // focus indicator stays on the same edge. Same logic for error.
222
+ const wrapToIdleShape = colour => typeof borderIdleDefault === 'string' ? colour : {
223
+ bottom: colour
224
+ };
225
+ const borderIdle = expandColorSides(override?.borderIdle ?? tokenSet?.borderIdle ?? borderIdleDefault);
226
+ const borderFocused = expandColorSides(override?.borderFocused ?? tokenSet?.borderFocused ?? wrapToIdleShape(theme.colors.border.focus));
227
+ const borderError = expandColorSides(override?.borderError ?? tokenSet?.borderError ?? wrapToIdleShape(theme.colors.border.error));
228
+ const borderDisabled = expandColorSides(override?.borderDisabled ?? tokenSet?.borderDisabled ?? borderIdleDefault);
229
+ const borderWidth = expandWidthSides(override?.borderWidth ?? tokenSet?.borderWidth ?? borderWidthDefault);
132
230
  return {
133
231
  backgroundIdleEmpty: override?.backgroundIdleEmpty ?? tokenSet?.backgroundIdleEmpty ?? idleEmpty,
134
232
  backgroundIdleFilled: override?.backgroundIdleFilled ?? tokenSet?.backgroundIdleFilled ?? override?.backgroundIdleEmpty ?? tokenSet?.backgroundIdleEmpty ?? idleEmpty,
135
233
  backgroundFocused: override?.backgroundFocused ?? tokenSet?.backgroundFocused ?? undefined,
136
234
  backgroundError: override?.backgroundError ?? tokenSet?.backgroundError ?? undefined,
137
235
  backgroundDisabled: override?.backgroundDisabled ?? tokenSet?.backgroundDisabled ?? theme.colors.surface.disabled,
138
- borderIdle: override?.borderIdle ?? tokenSet?.borderIdle ?? borderIdle,
139
- borderFocused: override?.borderFocused ?? tokenSet?.borderFocused ?? theme.colors.border.focus,
140
- borderError: override?.borderError ?? tokenSet?.borderError ?? theme.colors.border.error,
141
- borderDisabled: override?.borderDisabled ?? tokenSet?.borderDisabled ?? borderIdle,
142
- borderWidth: override?.borderWidth ?? tokenSet?.borderWidth ?? borderWidth
236
+ borderIdle,
237
+ borderFocused,
238
+ borderError,
239
+ borderDisabled,
240
+ borderWidth,
241
+ borderFocusedRepresentative: pickRepresentativeBorderColor(borderFocused)
143
242
  };
144
243
  };
145
244
  export const FieldBase = props => {
@@ -161,7 +260,15 @@ export const FieldBase = props => {
161
260
  paddingHorizontal: paddingHorizontalProp,
162
261
  paddingVertical: paddingVerticalProp,
163
262
  borderRadius: borderRadiusProp,
263
+ borderTopLeftRadius: borderTopLeftRadiusProp,
264
+ borderTopRightRadius: borderTopRightRadiusProp,
265
+ borderBottomLeftRadius: borderBottomLeftRadiusProp,
266
+ borderBottomRightRadius: borderBottomRightRadiusProp,
164
267
  borderWidth: borderWidthProp,
268
+ borderTopWidth: borderTopWidthProp,
269
+ borderRightWidth: borderRightWidthProp,
270
+ borderBottomWidth: borderBottomWidthProp,
271
+ borderLeftWidth: borderLeftWidthProp,
165
272
  gap: gapProp,
166
273
  fillOverrides,
167
274
  style,
@@ -200,13 +307,6 @@ export const FieldBase = props => {
200
307
  const idleFill = filled ? colors.backgroundIdleFilled : colors.backgroundIdleEmpty;
201
308
  const focusedFill = colors.backgroundFocused ?? idleFill;
202
309
  const errorFill = colors.backgroundError ?? idleFill;
203
- const animatedBorderColor = disabled ? colors.borderDisabled : error ? errorAnim.interpolate({
204
- inputRange: [0, 1],
205
- outputRange: [colors.borderIdle, colors.borderError]
206
- }) : focusAnim.interpolate({
207
- inputRange: [0, 1],
208
- outputRange: [colors.borderIdle, colors.borderFocused]
209
- });
210
310
  const animatedBackgroundColor = disabled ? colors.backgroundDisabled : error ? errorAnim.interpolate({
211
311
  inputRange: [0, 1],
212
312
  outputRange: [idleFill, errorFill]
@@ -214,17 +314,105 @@ export const FieldBase = props => {
214
314
  inputRange: [0, 1],
215
315
  outputRange: [idleFill, focusedFill]
216
316
  });
317
+
318
+ // Per-side border colour. For each side we resolve the "from" (idle) and
319
+ // "to" (focused or error, depending on state) colour, then drive a single
320
+ // animated interpolation. When all four sides share the same from+to pair
321
+ // we collapse to one `borderColor` key — the common case stays cheap.
322
+ const activeColors = error ? colors.borderError : colors.borderFocused;
323
+ const activeAnim = error ? errorAnim : focusAnim;
324
+ const sideFrom = disabled ? colors.borderDisabled : colors.borderIdle;
325
+ const sideTo = disabled ? colors.borderDisabled : activeColors;
326
+
327
+ // Per-side widths: longhand prop wins over shorthand prop wins over
328
+ // resolved variant token. Each side independently.
329
+ const propWidth = {
330
+ top: borderTopWidthProp,
331
+ right: borderRightWidthProp,
332
+ bottom: borderBottomWidthProp,
333
+ left: borderLeftWidthProp
334
+ };
335
+ const resolvedWidths = {
336
+ top: propWidth.top ?? borderWidthProp ?? colors.borderWidth.top,
337
+ right: propWidth.right ?? borderWidthProp ?? colors.borderWidth.right,
338
+ bottom: propWidth.bottom ?? borderWidthProp ?? colors.borderWidth.bottom,
339
+ left: propWidth.left ?? borderWidthProp ?? colors.borderWidth.left
340
+ };
217
341
  const boxStyle = {
218
342
  minHeight: height ?? minHeightProp ?? sizeTokens.minHeight,
219
343
  maxHeight,
220
344
  paddingHorizontal: paddingHorizontalProp ?? sizeTokens.paddingHorizontal,
221
345
  paddingVertical: paddingVerticalProp ?? sizeTokens.paddingVertical,
222
- borderRadius: borderRadiusProp ?? sizeTokens.borderRadius,
223
- borderWidth: borderWidthProp ?? colors.borderWidth,
224
- borderColor: animatedBorderColor,
225
346
  backgroundColor: animatedBackgroundColor,
226
347
  columnGap: gapProp ?? theme.spacing.sm
227
348
  };
349
+
350
+ // Radius: per-corner prop wins over shorthand prop wins over size token.
351
+ // Collapse to single `borderRadius` when all four corners match.
352
+ const radiusShorthand = borderRadiusProp ?? sizeTokens.borderRadius;
353
+ const corners = {
354
+ tl: borderTopLeftRadiusProp ?? radiusShorthand,
355
+ tr: borderTopRightRadiusProp ?? radiusShorthand,
356
+ bl: borderBottomLeftRadiusProp ?? radiusShorthand,
357
+ br: borderBottomRightRadiusProp ?? radiusShorthand
358
+ };
359
+ if (corners.tl === corners.tr && corners.tr === corners.bl && corners.bl === corners.br) {
360
+ boxStyle.borderRadius = corners.tl;
361
+ } else {
362
+ boxStyle.borderTopLeftRadius = corners.tl;
363
+ boxStyle.borderTopRightRadius = corners.tr;
364
+ boxStyle.borderBottomLeftRadius = corners.bl;
365
+ boxStyle.borderBottomRightRadius = corners.br;
366
+ }
367
+
368
+ // Width: collapse to single `borderWidth` when all four sides match.
369
+ if (resolvedWidths.top === resolvedWidths.right && resolvedWidths.right === resolvedWidths.bottom && resolvedWidths.bottom === resolvedWidths.left) {
370
+ boxStyle.borderWidth = resolvedWidths.top;
371
+ } else {
372
+ boxStyle.borderTopWidth = resolvedWidths.top;
373
+ boxStyle.borderRightWidth = resolvedWidths.right;
374
+ boxStyle.borderBottomWidth = resolvedWidths.bottom;
375
+ boxStyle.borderLeftWidth = resolvedWidths.left;
376
+ }
377
+
378
+ // Colour: collapse to single `borderColor` only when both endpoints are
379
+ // identical across all sides. Otherwise emit four `borderXColor`
380
+ // interpolations. Disabled state is static (no animation needed).
381
+ const fromAllEqual = allSidesEqual(sideFrom);
382
+ const toAllEqual = allSidesEqual(sideTo);
383
+ if (disabled) {
384
+ if (fromAllEqual) {
385
+ boxStyle.borderColor = sideFrom.top;
386
+ } else {
387
+ boxStyle.borderTopColor = sideFrom.top;
388
+ boxStyle.borderRightColor = sideFrom.right;
389
+ boxStyle.borderBottomColor = sideFrom.bottom;
390
+ boxStyle.borderLeftColor = sideFrom.left;
391
+ }
392
+ } else if (fromAllEqual && toAllEqual && sideFrom.top === sideFrom.right && sideTo.top === sideTo.right) {
393
+ // Common case: shorthand on both ends → single interpolation.
394
+ boxStyle.borderColor = activeAnim.interpolate({
395
+ inputRange: [0, 1],
396
+ outputRange: [sideFrom.top, sideTo.top]
397
+ });
398
+ } else {
399
+ boxStyle.borderTopColor = activeAnim.interpolate({
400
+ inputRange: [0, 1],
401
+ outputRange: [sideFrom.top, sideTo.top]
402
+ });
403
+ boxStyle.borderRightColor = activeAnim.interpolate({
404
+ inputRange: [0, 1],
405
+ outputRange: [sideFrom.right, sideTo.right]
406
+ });
407
+ boxStyle.borderBottomColor = activeAnim.interpolate({
408
+ inputRange: [0, 1],
409
+ outputRange: [sideFrom.bottom, sideTo.bottom]
410
+ });
411
+ boxStyle.borderLeftColor = activeAnim.interpolate({
412
+ inputRange: [0, 1],
413
+ outputRange: [sideFrom.left, sideTo.left]
414
+ });
415
+ }
228
416
  if (width !== undefined) boxStyle.width = width;
229
417
  if (height !== undefined) boxStyle.height = height;
230
418
  const a11yState = {
@@ -163,7 +163,7 @@ const Input = /*#__PURE__*/forwardRef((props, ref) => {
163
163
  // Selection / caret / handle colour walks the same resolution chain as the
164
164
  // focused border so brands that override `components.field.<variant>.borderFocused`
165
165
  // get a matching selection tint without also having to update root `border.focus`.
166
- const selectionColor = useMemo(() => resolveVariantColors(theme, variant, fillOverrides).borderFocused, [theme, variant, fillOverrides]);
166
+ const selectionColor = useMemo(() => resolveVariantColors(theme, variant, fillOverrides).borderFocusedRepresentative, [theme, variant, fillOverrides]);
167
167
  const borderRadiusOverride = borderRadiusProp ?? theme.components.input?.borderRadius;
168
168
 
169
169
  // Floating label styles
@@ -35,7 +35,13 @@ const SearchBar = /*#__PURE__*/forwardRef((props, ref) => {
35
35
  // field-family token (`components.field.defaultVariant`) → library default.
36
36
  // Library default stays `'filled'` so SearchBar reads as the traditional
37
37
  // pill-shaped, borderless search box when no theme opinion is expressed.
38
- const variant = variantProp ?? theme.components.searchBar?.defaultVariant ?? theme.components.field?.defaultVariant ?? 'filled';
38
+ // SearchBar deliberately only supports 'filled' and 'outlined' an
39
+ // underline-only pill makes no visual sense. If the shared field default is
40
+ // 'underline', fall through to 'filled' rather than carry an unsupported
41
+ // value down to the variant token lookup.
42
+ const sharedDefault = theme.components.field?.defaultVariant;
43
+ const sharedFallback = sharedDefault === 'outlined' || sharedDefault === 'filled' ? sharedDefault : 'filled';
44
+ const variant = variantProp ?? theme.components.searchBar?.defaultVariant ?? sharedFallback;
39
45
  const fieldTokens = resolveFieldSize(theme, size);
40
46
  const sizeStyles = {
41
47
  ...fieldTokens,
@@ -179,7 +185,7 @@ const SearchBar = /*#__PURE__*/forwardRef((props, ref) => {
179
185
  autoFocus: autoFocus,
180
186
  editable: !disabled,
181
187
  returnKeyType: "search",
182
- selectionColor: resolveVariantColors(theme, variant).borderFocused,
188
+ selectionColor: resolveVariantColors(theme, variant).borderFocusedRepresentative,
183
189
  accessibilityLabel: accessibilityLabel ?? placeholder,
184
190
  accessibilityState: {
185
191
  disabled
@@ -321,7 +321,7 @@ const Select = /*#__PURE__*/forwardRef((props, ref) => {
321
321
  onChangeText: setQuery,
322
322
  placeholder: "Search\u2026",
323
323
  placeholderTextColor: theme.colors.text.tertiary,
324
- selectionColor: resolveVariantColors(theme, theme.components.field?.defaultVariant ?? 'outlined').borderFocused,
324
+ selectionColor: resolveVariantColors(theme, theme.components.field?.defaultVariant ?? 'outlined').borderFocusedRepresentative,
325
325
  accessibilityLabel: "Search options",
326
326
  style: [styles.searchInput, {
327
327
  color: theme.colors.text.primary,
@@ -23,9 +23,17 @@
23
23
  */
24
24
  import React from 'react';
25
25
  import type { AccessibilityRole, AccessibilityState, StyleProp, TextStyle, ViewStyle } from 'react-native';
26
- import type { FieldSizeTokens, FieldVariantTokens, Theme } from '../../theme/types';
26
+ import type { FieldSide, FieldSizeTokens, FieldVariantTokens, Theme } from '../../theme/types';
27
27
  export type FieldBaseSize = 'sm' | 'md' | 'lg';
28
- export type FieldBaseVariant = 'outlined' | 'filled';
28
+ export type FieldBaseVariant = 'outlined' | 'filled' | 'underline';
29
+ /**
30
+ * Pick the most "representative" side colour from a 4-side record. Used by
31
+ * consumers (Input's selectionColor, focus rings, etc.) that need a single
32
+ * colour to mirror the visible focus indicator. Priority: bottom → top →
33
+ * left → right, skipping `'transparent'`. The bottom-first order means
34
+ * underline variants pick their underline colour automatically.
35
+ */
36
+ export declare const pickRepresentativeBorderColor: (sides: Record<FieldSide, string>) => string;
29
37
  export interface FieldBaseProps {
30
38
  /** Resolves to dimension tokens at `theme.components.field[size]`. Default `'md'`. */
31
39
  size?: FieldBaseSize;
@@ -65,10 +73,26 @@ export interface FieldBaseProps {
65
73
  paddingHorizontal?: number;
66
74
  /** Override vertical padding (single-line). Multiline parents usually leave this default. */
67
75
  paddingVertical?: number;
68
- /** Override corner radius. SearchBar pill uses this to apply `radius.full`. */
76
+ /** Override corner radius (shorthand — all four corners). SearchBar pill uses this to apply `radius.full`. */
69
77
  borderRadius?: number;
70
- /** Override border width. OTP uses 1.5; everything else inherits `colors.border.width`. */
78
+ /** Top-left corner radius. Wins over `borderRadius`. */
79
+ borderTopLeftRadius?: number;
80
+ /** Top-right corner radius. Wins over `borderRadius`. */
81
+ borderTopRightRadius?: number;
82
+ /** Bottom-left corner radius. Wins over `borderRadius`. */
83
+ borderBottomLeftRadius?: number;
84
+ /** Bottom-right corner radius. Wins over `borderRadius`. */
85
+ borderBottomRightRadius?: number;
86
+ /** Override border width (shorthand — all four sides). OTP uses 1.5; everything else inherits `colors.border.width`. */
71
87
  borderWidth?: number;
88
+ /** Top border width. Wins over `borderWidth`. */
89
+ borderTopWidth?: number;
90
+ /** Right border width. Wins over `borderWidth`. */
91
+ borderRightWidth?: number;
92
+ /** Bottom border width. Wins over `borderWidth`. */
93
+ borderBottomWidth?: number;
94
+ /** Left border width. Wins over `borderWidth`. */
95
+ borderLeftWidth?: number;
72
96
  /** Gap between leading / children / trailing. Defaults to `theme.spacing.sm`. */
73
97
  gap?: number;
74
98
  fillOverrides?: Partial<FieldVariantTokens>;
@@ -114,8 +138,11 @@ export declare const resolveFieldTextStyle: (theme: Theme, options?: FieldTextSt
114
138
  export declare const resolveFieldSize: (theme: Theme, size: FieldBaseSize) => FieldSizeTokens;
115
139
  /**
116
140
  * Strict, fully-resolved colour set used internally. Mirrors `FieldVariantTokens`
117
- * but every field is non-optional every state has a concrete colour by the
118
- * time the box renders, so downstream code can rely on string-typed fills.
141
+ * but every border colour / width is expanded to a 4-side record by the time
142
+ * the box renders, so the animation pipeline can treat per-side and shorthand
143
+ * the same way. `borderFocusedRepresentative` is a single colour pulled from
144
+ * the focused sides — exposed for consumers (Input's selectionColor) that
145
+ * need to mirror the visible focus indicator without re-resolving per side.
119
146
  */
120
147
  interface ResolvedFieldColors {
121
148
  backgroundIdleEmpty: string;
@@ -125,15 +152,19 @@ interface ResolvedFieldColors {
125
152
  /** Optional override fill while in error — when undefined, error animates from idle to idle. */
126
153
  backgroundError: string | undefined;
127
154
  backgroundDisabled: string;
128
- borderIdle: string;
129
- borderFocused: string;
130
- borderError: string;
131
- borderDisabled: string;
132
- borderWidth: number;
155
+ borderIdle: Record<FieldSide, string>;
156
+ borderFocused: Record<FieldSide, string>;
157
+ borderError: Record<FieldSide, string>;
158
+ borderDisabled: Record<FieldSide, string>;
159
+ borderWidth: Record<FieldSide, number>;
160
+ /** Single representative colour from `borderFocused` — first non-transparent side, bottom-priority. */
161
+ borderFocusedRepresentative: string;
133
162
  }
134
163
  /**
135
164
  * Resolve the full set of state-aware fill + border colours for a given
136
- * variant. Order: explicit override → theme token → library default.
165
+ * variant. Order: explicit override → theme token → library default. Border
166
+ * colour + width are normalized to per-side records here; callers that need
167
+ * a single colour read `borderFocusedRepresentative`.
137
168
  */
138
169
  export declare const resolveVariantColors: (theme: Theme, variant: FieldBaseVariant, override?: Partial<FieldVariantTokens>) => ResolvedFieldColors;
139
170
  export declare const FieldBase: React.FC<FieldBaseProps>;
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { TextInput } from 'react-native';
3
3
  import type { BlurEvent, FocusEvent, StyleProp, TextInputProps, TextStyle, ViewStyle } from 'react-native';
4
4
  export type InputSize = 'sm' | 'md' | 'lg';
5
- export type InputVariant = 'outlined' | 'filled';
5
+ export type InputVariant = 'outlined' | 'filled' | 'underline';
6
6
  export type InputLabelMode = 'float' | 'top';
7
7
  export interface InputProps extends Omit<TextInputProps, 'style'> {
8
8
  label?: string;
@@ -391,12 +391,33 @@ export interface FieldSizeTokens {
391
391
  borderRadius: number;
392
392
  iconSize: number;
393
393
  }
394
+ /**
395
+ * One of the four field-box sides. Used everywhere the library accepts
396
+ * per-side overrides for border width / colour.
397
+ */
398
+ export type FieldSide = 'top' | 'right' | 'bottom' | 'left';
399
+ /**
400
+ * Shorthand-or-longhand value. A bare `T` means "apply to all four sides";
401
+ * an object form lets a consumer set specific sides while the others fall
402
+ * through to whatever the resolver decided. Mirrors how React Native's
403
+ * `borderWidth` + `borderXWidth` (and CSS `border` + `border-X`) compose.
404
+ */
405
+ export type FieldPerSide<T> = T | Partial<Record<FieldSide, T>>;
406
+ /** Border colour value: a single colour string or a per-side colour map. */
407
+ export type FieldBorderColor = FieldPerSide<string>;
408
+ /** Border width value: a single width or a per-side width map. */
409
+ export type FieldBorderWidth = FieldPerSide<number>;
394
410
  /**
395
411
  * State-aware fill + border colours for a field variant. Resolution order
396
412
  * inside FieldBase: variant token → library default. Idle distinguishes
397
413
  * empty vs filled so consumers can tint the resting field differently once
398
414
  * the user has typed. When focused/error/disabled fills are omitted, the
399
415
  * box animates from the current idle fill to itself (no fill change).
416
+ *
417
+ * Border colour + width are `FieldPerSide` values — pass a bare string /
418
+ * number to set all four sides (the common case), or an object like
419
+ * `{ bottom: '#1A73E8' }` to draw only one side. Sides not specified in the
420
+ * object fall through to the shorthand default.
400
421
  */
401
422
  export interface FieldVariantTokens {
402
423
  backgroundIdleEmpty: string;
@@ -404,18 +425,20 @@ export interface FieldVariantTokens {
404
425
  backgroundFocused?: string;
405
426
  backgroundError?: string;
406
427
  backgroundDisabled: string;
407
- borderIdle: string;
408
- borderFocused: string;
409
- borderError: string;
410
- borderDisabled: string;
411
- borderWidth: number;
428
+ borderIdle: FieldBorderColor;
429
+ borderFocused: FieldBorderColor;
430
+ borderError: FieldBorderColor;
431
+ borderDisabled: FieldBorderColor;
432
+ borderWidth: FieldBorderWidth;
412
433
  }
413
434
  export interface FieldTokens extends Partial<Record<'sm' | 'md' | 'lg', FieldSizeTokens>> {
414
435
  /** Default `variant` when caller doesn't pass one. Library default: 'outlined'. */
415
- defaultVariant?: 'outlined' | 'filled';
436
+ defaultVariant?: 'outlined' | 'filled' | 'underline';
416
437
  /** State-aware fill + border colours per variant. */
417
438
  outlined?: Partial<FieldVariantTokens>;
418
439
  filled?: Partial<FieldVariantTokens>;
440
+ /** Bottom-border-only variant — Material-style underline inputs. */
441
+ underline?: Partial<FieldVariantTokens>;
419
442
  /** Text colour for the editable / displayed value. Defaults to `colors.text.primary`. */
420
443
  textColor?: string;
421
444
  /** Text colour when the field is disabled. Defaults to `colors.text.disabled`. */
@@ -439,7 +462,7 @@ export interface InputFillTokens {
439
462
  }
440
463
  export interface InputComponentTokens extends Partial<Record<ComponentSizeKey, InputSizeTokens>> {
441
464
  /** Default `variant` when caller doesn't pass one. Library default: 'outlined'. */
442
- defaultVariant?: 'outlined' | 'filled';
465
+ defaultVariant?: 'outlined' | 'filled' | 'underline';
443
466
  /** Default `labelMode` when caller doesn't pass one. Library default: 'float'. */
444
467
  defaultLabelMode?: 'float' | 'top';
445
468
  /** Corner radius in pixels for the field wrapper. Library default: size-driven (sm=10, md=12, lg=14). */
@@ -468,6 +491,7 @@ export interface InputComponentTokens extends Partial<Record<ComponentSizeKey, I
468
491
  fillColors?: {
469
492
  outlined?: InputFillTokens;
470
493
  filled?: InputFillTokens;
494
+ underline?: InputFillTokens;
471
495
  };
472
496
  }
473
497
  export interface BottomSheetTokens {
@@ -23,9 +23,17 @@
23
23
  */
24
24
  import React from 'react';
25
25
  import type { AccessibilityRole, AccessibilityState, StyleProp, TextStyle, ViewStyle } from 'react-native';
26
- import type { FieldSizeTokens, FieldVariantTokens, Theme } from '../../theme/types';
26
+ import type { FieldSide, FieldSizeTokens, FieldVariantTokens, Theme } from '../../theme/types';
27
27
  export type FieldBaseSize = 'sm' | 'md' | 'lg';
28
- export type FieldBaseVariant = 'outlined' | 'filled';
28
+ export type FieldBaseVariant = 'outlined' | 'filled' | 'underline';
29
+ /**
30
+ * Pick the most "representative" side colour from a 4-side record. Used by
31
+ * consumers (Input's selectionColor, focus rings, etc.) that need a single
32
+ * colour to mirror the visible focus indicator. Priority: bottom → top →
33
+ * left → right, skipping `'transparent'`. The bottom-first order means
34
+ * underline variants pick their underline colour automatically.
35
+ */
36
+ export declare const pickRepresentativeBorderColor: (sides: Record<FieldSide, string>) => string;
29
37
  export interface FieldBaseProps {
30
38
  /** Resolves to dimension tokens at `theme.components.field[size]`. Default `'md'`. */
31
39
  size?: FieldBaseSize;
@@ -65,10 +73,26 @@ export interface FieldBaseProps {
65
73
  paddingHorizontal?: number;
66
74
  /** Override vertical padding (single-line). Multiline parents usually leave this default. */
67
75
  paddingVertical?: number;
68
- /** Override corner radius. SearchBar pill uses this to apply `radius.full`. */
76
+ /** Override corner radius (shorthand — all four corners). SearchBar pill uses this to apply `radius.full`. */
69
77
  borderRadius?: number;
70
- /** Override border width. OTP uses 1.5; everything else inherits `colors.border.width`. */
78
+ /** Top-left corner radius. Wins over `borderRadius`. */
79
+ borderTopLeftRadius?: number;
80
+ /** Top-right corner radius. Wins over `borderRadius`. */
81
+ borderTopRightRadius?: number;
82
+ /** Bottom-left corner radius. Wins over `borderRadius`. */
83
+ borderBottomLeftRadius?: number;
84
+ /** Bottom-right corner radius. Wins over `borderRadius`. */
85
+ borderBottomRightRadius?: number;
86
+ /** Override border width (shorthand — all four sides). OTP uses 1.5; everything else inherits `colors.border.width`. */
71
87
  borderWidth?: number;
88
+ /** Top border width. Wins over `borderWidth`. */
89
+ borderTopWidth?: number;
90
+ /** Right border width. Wins over `borderWidth`. */
91
+ borderRightWidth?: number;
92
+ /** Bottom border width. Wins over `borderWidth`. */
93
+ borderBottomWidth?: number;
94
+ /** Left border width. Wins over `borderWidth`. */
95
+ borderLeftWidth?: number;
72
96
  /** Gap between leading / children / trailing. Defaults to `theme.spacing.sm`. */
73
97
  gap?: number;
74
98
  fillOverrides?: Partial<FieldVariantTokens>;
@@ -114,8 +138,11 @@ export declare const resolveFieldTextStyle: (theme: Theme, options?: FieldTextSt
114
138
  export declare const resolveFieldSize: (theme: Theme, size: FieldBaseSize) => FieldSizeTokens;
115
139
  /**
116
140
  * Strict, fully-resolved colour set used internally. Mirrors `FieldVariantTokens`
117
- * but every field is non-optional every state has a concrete colour by the
118
- * time the box renders, so downstream code can rely on string-typed fills.
141
+ * but every border colour / width is expanded to a 4-side record by the time
142
+ * the box renders, so the animation pipeline can treat per-side and shorthand
143
+ * the same way. `borderFocusedRepresentative` is a single colour pulled from
144
+ * the focused sides — exposed for consumers (Input's selectionColor) that
145
+ * need to mirror the visible focus indicator without re-resolving per side.
119
146
  */
120
147
  interface ResolvedFieldColors {
121
148
  backgroundIdleEmpty: string;
@@ -125,15 +152,19 @@ interface ResolvedFieldColors {
125
152
  /** Optional override fill while in error — when undefined, error animates from idle to idle. */
126
153
  backgroundError: string | undefined;
127
154
  backgroundDisabled: string;
128
- borderIdle: string;
129
- borderFocused: string;
130
- borderError: string;
131
- borderDisabled: string;
132
- borderWidth: number;
155
+ borderIdle: Record<FieldSide, string>;
156
+ borderFocused: Record<FieldSide, string>;
157
+ borderError: Record<FieldSide, string>;
158
+ borderDisabled: Record<FieldSide, string>;
159
+ borderWidth: Record<FieldSide, number>;
160
+ /** Single representative colour from `borderFocused` — first non-transparent side, bottom-priority. */
161
+ borderFocusedRepresentative: string;
133
162
  }
134
163
  /**
135
164
  * Resolve the full set of state-aware fill + border colours for a given
136
- * variant. Order: explicit override → theme token → library default.
165
+ * variant. Order: explicit override → theme token → library default. Border
166
+ * colour + width are normalized to per-side records here; callers that need
167
+ * a single colour read `borderFocusedRepresentative`.
137
168
  */
138
169
  export declare const resolveVariantColors: (theme: Theme, variant: FieldBaseVariant, override?: Partial<FieldVariantTokens>) => ResolvedFieldColors;
139
170
  export declare const FieldBase: React.FC<FieldBaseProps>;
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { TextInput } from 'react-native';
3
3
  import type { BlurEvent, FocusEvent, StyleProp, TextInputProps, TextStyle, ViewStyle } from 'react-native';
4
4
  export type InputSize = 'sm' | 'md' | 'lg';
5
- export type InputVariant = 'outlined' | 'filled';
5
+ export type InputVariant = 'outlined' | 'filled' | 'underline';
6
6
  export type InputLabelMode = 'float' | 'top';
7
7
  export interface InputProps extends Omit<TextInputProps, 'style'> {
8
8
  label?: string;
@@ -391,12 +391,33 @@ export interface FieldSizeTokens {
391
391
  borderRadius: number;
392
392
  iconSize: number;
393
393
  }
394
+ /**
395
+ * One of the four field-box sides. Used everywhere the library accepts
396
+ * per-side overrides for border width / colour.
397
+ */
398
+ export type FieldSide = 'top' | 'right' | 'bottom' | 'left';
399
+ /**
400
+ * Shorthand-or-longhand value. A bare `T` means "apply to all four sides";
401
+ * an object form lets a consumer set specific sides while the others fall
402
+ * through to whatever the resolver decided. Mirrors how React Native's
403
+ * `borderWidth` + `borderXWidth` (and CSS `border` + `border-X`) compose.
404
+ */
405
+ export type FieldPerSide<T> = T | Partial<Record<FieldSide, T>>;
406
+ /** Border colour value: a single colour string or a per-side colour map. */
407
+ export type FieldBorderColor = FieldPerSide<string>;
408
+ /** Border width value: a single width or a per-side width map. */
409
+ export type FieldBorderWidth = FieldPerSide<number>;
394
410
  /**
395
411
  * State-aware fill + border colours for a field variant. Resolution order
396
412
  * inside FieldBase: variant token → library default. Idle distinguishes
397
413
  * empty vs filled so consumers can tint the resting field differently once
398
414
  * the user has typed. When focused/error/disabled fills are omitted, the
399
415
  * box animates from the current idle fill to itself (no fill change).
416
+ *
417
+ * Border colour + width are `FieldPerSide` values — pass a bare string /
418
+ * number to set all four sides (the common case), or an object like
419
+ * `{ bottom: '#1A73E8' }` to draw only one side. Sides not specified in the
420
+ * object fall through to the shorthand default.
400
421
  */
401
422
  export interface FieldVariantTokens {
402
423
  backgroundIdleEmpty: string;
@@ -404,18 +425,20 @@ export interface FieldVariantTokens {
404
425
  backgroundFocused?: string;
405
426
  backgroundError?: string;
406
427
  backgroundDisabled: string;
407
- borderIdle: string;
408
- borderFocused: string;
409
- borderError: string;
410
- borderDisabled: string;
411
- borderWidth: number;
428
+ borderIdle: FieldBorderColor;
429
+ borderFocused: FieldBorderColor;
430
+ borderError: FieldBorderColor;
431
+ borderDisabled: FieldBorderColor;
432
+ borderWidth: FieldBorderWidth;
412
433
  }
413
434
  export interface FieldTokens extends Partial<Record<'sm' | 'md' | 'lg', FieldSizeTokens>> {
414
435
  /** Default `variant` when caller doesn't pass one. Library default: 'outlined'. */
415
- defaultVariant?: 'outlined' | 'filled';
436
+ defaultVariant?: 'outlined' | 'filled' | 'underline';
416
437
  /** State-aware fill + border colours per variant. */
417
438
  outlined?: Partial<FieldVariantTokens>;
418
439
  filled?: Partial<FieldVariantTokens>;
440
+ /** Bottom-border-only variant — Material-style underline inputs. */
441
+ underline?: Partial<FieldVariantTokens>;
419
442
  /** Text colour for the editable / displayed value. Defaults to `colors.text.primary`. */
420
443
  textColor?: string;
421
444
  /** Text colour when the field is disabled. Defaults to `colors.text.disabled`. */
@@ -439,7 +462,7 @@ export interface InputFillTokens {
439
462
  }
440
463
  export interface InputComponentTokens extends Partial<Record<ComponentSizeKey, InputSizeTokens>> {
441
464
  /** Default `variant` when caller doesn't pass one. Library default: 'outlined'. */
442
- defaultVariant?: 'outlined' | 'filled';
465
+ defaultVariant?: 'outlined' | 'filled' | 'underline';
443
466
  /** Default `labelMode` when caller doesn't pass one. Library default: 'float'. */
444
467
  defaultLabelMode?: 'float' | 'top';
445
468
  /** Corner radius in pixels for the field wrapper. Library default: size-driven (sm=10, md=12, lg=14). */
@@ -468,6 +491,7 @@ export interface InputComponentTokens extends Partial<Record<ComponentSizeKey, I
468
491
  fillColors?: {
469
492
  outlined?: InputFillTokens;
470
493
  filled?: InputFillTokens;
494
+ underline?: InputFillTokens;
471
495
  };
472
496
  }
473
497
  export interface BottomSheetTokens {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority-technologies/mobile",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "description": "Beautiful, animated, accessible React Native components plus API/auth/logging/network/storage utilities for Webority projects.",
5
5
  "keywords": [
6
6
  "react-native",