@colixsystems/widget-sdk 0.248.0 → 0.249.0
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/README.md +25 -3
- package/dist/contract.cjs +7 -3
- package/dist/contract.js +7 -3
- package/dist/index.d.ts +31 -4
- package/dist/linter.cjs +156 -0
- package/dist/linter.js +156 -0
- package/dist/search-field-tokens.js +4 -6
- package/dist/search-field-view.js +43 -123
- package/dist/text-field-adornment.js +93 -0
- package/dist/text-field-view.js +102 -8
- package/package.json +1 -1
- package/src/contract.js +7 -3
package/README.md
CHANGED
|
@@ -84,7 +84,29 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.249.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
88
|
+
|
|
89
|
+
### What's new in 0.249.0 (contract 1.203.0)
|
|
90
|
+
|
|
91
|
+
**Icons inside a text field.** `<TextField>` takes `leadingIcon` and `trailingIcon`, each a lucide name, and draws the icon inside its own border with the text kept clear of it. A plain icon is hidden from screen readers and lets a tap through to the field. Pass `onTrailingPress` and `trailingAccessibilityLabel` to make the trailing icon a button (a clear control, "use my location"); it is disabled with the field. A field with an icon is no longer wrapped in a bordered row of your own, which drew a box inside a box:
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
<TextField
|
|
95
|
+
label={t("pickup", "Pickup address")}
|
|
96
|
+
value={address}
|
|
97
|
+
onChangeText={setAddress}
|
|
98
|
+
leadingIcon="map-pin"
|
|
99
|
+
trailingIcon="locate"
|
|
100
|
+
onTrailingPress={useMyLocation}
|
|
101
|
+
trailingAccessibilityLabel={t("use_location", "Use my location")}
|
|
102
|
+
/>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- A field with icon props sits in a box: `containerStyle` styles it even without a label, and the flex, margin, width and position keys of `style` move to it, so `style={{ flex: 1 }}` still sizes the field in a row. A field without icon props renders exactly as before.
|
|
106
|
+
- Passing an icon prop at all keeps the box, so `trailingIcon={text ? "x" : undefined}` never rebuilds the input while someone types.
|
|
107
|
+
- The slots are named `${testID}-leading-icon` / `${testID}-trailing-icon`, or `leadingTestID` / `trailingTestID`.
|
|
108
|
+
- `<SearchField>` draws its glyph and clear control through these slots; it looks and behaves as before, and its `containerStyle` reaches its outermost box with or without a label.
|
|
109
|
+
- **New linter rule `field-in-bordered-row` (severity `warning`, non-blocking).** It flags a `<TextField>` or `<SearchField>` that is a direct child of a row (`flexDirection: "row"`) with a non-zero `borderWidth`, read from the row's inline style and the StyleSheet entries it names. A bordered column, such as a card holding a form, is not flagged. Its sibling **`field-trailing-press-unnamed`** (also a warning) flags `onTrailingPress` with no `trailingAccessibilityLabel`, a button a screen reader can only call "button"; the types require the label too.
|
|
88
110
|
|
|
89
111
|
### What's new in 0.248.0 (contract 1.202.0)
|
|
90
112
|
|
|
@@ -3134,7 +3156,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
3134
3156
|
- `Grid` — balanced lines of cells sized from the grid's own measured width, on both hosts. `<Grid minCellWidth gap balance>`: `balance` `"cards"` (default) fits as many cells to a line as `minCellWidth` allows (240 when unset), fills every line but the last and pads that one with empty cells; `"tiles"` spreads the remainder and stretches each line (5 at 4 per line are 3 + 2), with `maxColumns` or the width tier (4 / 3 / 2) as the ceiling. `columns` fixes the count per line; `cell={{ min, max }}` with `square` makes definite-size cells (calendar days, seats, swatches) in a centred block. It measures an inner box with no padding, and `gap` is a theme spacing name or a number. Each child keeps its own look, and a child with `flex: 1` fills its cell's height. Use it by default for card grids, tile rows and capped cells; masonry or spanning cells are yours to lay out with `useContainerWidth()`.
|
|
3135
3157
|
- `Form`, `FieldRow`, `FormSection`, `FormActions`, `FormField` — a form laid out in width tiers from its own measured width, on both hosts: `<Form columns maxWidth gap>` puts its fields one to a line when narrow, two at medium and up to three when wide (`columns` is the wide ceiling), capped near 1000px and centred (`maxWidth={null}` for none). A `<FormField>`, or a `<TextField>`, `<Select>`, `<RadioGroup>`, `<PeoplePicker>` or `<FileUpload>` with its own label, is one field; a multiline field, `<FormField span="full">`, `<FieldRow>` (short related fields on one line, stacked when narrow), `<FormSection title description>` and `<FormActions>` (Save / Cancel in normal flow, secondary first and primary last, a trailing row or a full-width stack when narrow) take the whole line. `<FormField label required helper error>` draws the form controls' label, required mark, helper and error round any control and names it for a screen reader; a control that frames its own label is handed them instead, so one label is drawn. `required` (also on `TextField`, `Select`, `RadioGroup`, `PeoplePicker`, `FileUpload`) draws the mark and adds "Required" to the spoken name; it does not validate. Use it by default; a multi-step wizard, a form beside a live preview or fields laid over a picture are yours to lay out with `useContainerWidth()`.
|
|
3136
3158
|
- `SortableList`, `Board` — drag and drop on both hosts. `<SortableList items onReorder renderItem>` is a list the user reorders, each row a `<ListRow>`; `<Board columns items getColumn onMove renderCard>` is cards moved between columns, each a `<Card>`. Both own the grip handle, the lifted copy, the vacated slot, the drop highlight in the soft primary, the spring back of a cancelled drag and a narrow board's sideways scroll, and give a user who cannot drag the same moves: a tap or Enter on the handle opens its move options, the arrow keys move it on the web, a screen reader gets them as actions, and each move is announced. `onReorder(nextItems, { from, to, item })` and `onMove(item, { fromColumn, toColumn, index })` run once per real change. Use them by default; a drag they cannot express (free-form positioning, a drag between two widgets) is yours to build with `Animated` + `PanResponder`.
|
|
3137
|
-
- `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Design > Components (Cards, Buttons, Inputs); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
|
|
3159
|
+
- `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Design > Components (Cards, Buttons, Inputs); never hand-roll one from `View` / `Pressable` / a bare `TextInput`. `<TextField leadingIcon trailingIcon onTrailingPress trailingAccessibilityLabel>` draws icons inside the field's own border.
|
|
3138
3160
|
- `Banner` — the themed inline message. `<Banner tone message>` shows an error, warning, success or notice in place, styled by the theme's `alert` scope; `action` adds one button and `onDismiss` a close control. Use `useToast()` for a brief confirmation instead. Never hand-roll a tinted error box.
|
|
3139
3161
|
- `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
|
|
3140
3162
|
- `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
|
|
@@ -3175,7 +3197,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3175
3197
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
3176
3198
|
- **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
|
|
3177
3199
|
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
|
|
3178
|
-
- **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. A search box is `<SearchField>`, which already carries its glyph and clear control.
|
|
3200
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. A search box is `<SearchField>`, which already carries its glyph and clear control. Any other icon goes inside the field through `leadingIcon` / `trailingIcon`; never wrap a `<TextField>` in a bordered row, since it draws its own border. Put a unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
|
|
3179
3201
|
- **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
|
|
3180
3202
|
- **Drag and drop — show what is being dragged.** Use `<SortableList>` for a list the user reorders and `<Board>` for cards moved between columns by default: they do everything this item describes, on both hosts, and give a user who cannot drag the same moves from the handle. When the request needs what they cannot do (free-form positioning on a canvas, a drag between two widgets), build your own as follows. A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
|
|
3181
3203
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
package/dist/contract.cjs
CHANGED
|
@@ -3526,7 +3526,7 @@ const PRIMITIVES = [
|
|
|
3526
3526
|
{
|
|
3527
3527
|
name: "TextField",
|
|
3528
3528
|
description:
|
|
3529
|
-
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3529
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field, or around the field and its icons. An icon goes INSIDE the field, never beside it: `leadingIcon` / `trailingIcon` take a lucide name (`<TextField trailingIcon="map-pin" … />`), and `onTrailingPress` with a `trailingAccessibilityLabel` makes the trailing one a button (a clear control, "use my location"). Never wrap a field in a bordered row of your own to put an icon or a button beside it: the field draws its own border, so that shows a box inside a box. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3530
3530
|
rnComponent: null,
|
|
3531
3531
|
docsUrl: null,
|
|
3532
3532
|
},
|
|
@@ -3547,7 +3547,7 @@ const PRIMITIVES = [
|
|
|
3547
3547
|
{
|
|
3548
3548
|
name: "SearchField",
|
|
3549
3549
|
description:
|
|
3550
|
-
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it
|
|
3550
|
+
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it; its glyph and clear control fill the icon slots of `TextField`, so it takes no `leadingIcon` / `trailingIcon`. Use it rather than a `TextInput` + an icon of your own; when the request needs a search box it cannot draw (a scope picker inside the box, a voice button beside the clear control), build your own in the input look of the theme.',
|
|
3551
3551
|
rnComponent: null,
|
|
3552
3552
|
docsUrl: null,
|
|
3553
3553
|
},
|
|
@@ -6326,7 +6326,11 @@ const CONTRACT = deepFreeze({
|
|
|
6326
6326
|
// `<DataTable>`, `<FilterBar>`, `<Form>` / `<FieldRow>` / `<FormSection>`
|
|
6327
6327
|
// / `<FormActions>` / `<FormField>`, `<SortableList>`, `<Board>`;
|
|
6328
6328
|
// `required` on TextField, Select, RadioGroup, PeoplePicker, FileUpload.
|
|
6329
|
-
|
|
6329
|
+
// 1.203.0: additive (sc-12232) — `<TextField>` takes `leadingIcon`,
|
|
6330
|
+
// `trailingIcon`, `onTrailingPress`, `trailingAccessibilityLabel` and the
|
|
6331
|
+
// slots' testIDs; `<SearchField>` draws through them. New warnings
|
|
6332
|
+
// `field-in-bordered-row`, `field-trailing-press-unnamed`.
|
|
6333
|
+
version: "1.203.0",
|
|
6330
6334
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6331
6335
|
hooks: HOOKS,
|
|
6332
6336
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -3526,7 +3526,7 @@ const PRIMITIVES = [
|
|
|
3526
3526
|
{
|
|
3527
3527
|
name: "TextField",
|
|
3528
3528
|
description:
|
|
3529
|
-
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3529
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field, or around the field and its icons. An icon goes INSIDE the field, never beside it: `leadingIcon` / `trailingIcon` take a lucide name (`<TextField trailingIcon="map-pin" … />`), and `onTrailingPress` with a `trailingAccessibilityLabel` makes the trailing one a button (a clear control, "use my location"). Never wrap a field in a bordered row of your own to put an icon or a button beside it: the field draws its own border, so that shows a box inside a box. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3530
3530
|
rnComponent: null,
|
|
3531
3531
|
docsUrl: null,
|
|
3532
3532
|
},
|
|
@@ -3547,7 +3547,7 @@ const PRIMITIVES = [
|
|
|
3547
3547
|
{
|
|
3548
3548
|
name: "SearchField",
|
|
3549
3549
|
description:
|
|
3550
|
-
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it
|
|
3550
|
+
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it; its glyph and clear control fill the icon slots of `TextField`, so it takes no `leadingIcon` / `trailingIcon`. Use it rather than a `TextInput` + an icon of your own; when the request needs a search box it cannot draw (a scope picker inside the box, a voice button beside the clear control), build your own in the input look of the theme.',
|
|
3551
3551
|
rnComponent: null,
|
|
3552
3552
|
docsUrl: null,
|
|
3553
3553
|
},
|
|
@@ -6322,7 +6322,11 @@ const CONTRACT = deepFreeze({
|
|
|
6322
6322
|
// `<DataTable>`, `<FilterBar>`, `<Form>` / `<FieldRow>` / `<FormSection>`
|
|
6323
6323
|
// / `<FormActions>` / `<FormField>`, `<SortableList>`, `<Board>`;
|
|
6324
6324
|
// `required` on TextField, Select, RadioGroup, PeoplePicker, FileUpload.
|
|
6325
|
-
|
|
6325
|
+
// 1.203.0: additive (sc-12232) — `<TextField>` takes `leadingIcon`,
|
|
6326
|
+
// `trailingIcon`, `onTrailingPress`, `trailingAccessibilityLabel` and the
|
|
6327
|
+
// slots' testIDs; `<SearchField>` draws through them. New warnings
|
|
6328
|
+
// `field-in-bordered-row`, `field-trailing-press-unnamed`.
|
|
6329
|
+
version: "1.203.0",
|
|
6326
6330
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6327
6331
|
hooks: HOOKS,
|
|
6328
6332
|
primitives: PRIMITIVES,
|
package/dist/index.d.ts
CHANGED
|
@@ -4376,17 +4376,44 @@ export interface FormFieldOverrides {
|
|
|
4376
4376
|
height?: number;
|
|
4377
4377
|
}
|
|
4378
4378
|
|
|
4379
|
+
/**
|
|
4380
|
+
* The icons `<TextField>` draws inside its own border. Passing either prop at
|
|
4381
|
+
* all, even `undefined`, keeps the box they sit in, so an icon shown only
|
|
4382
|
+
* with text never rebuilds the input.
|
|
4383
|
+
*/
|
|
4384
|
+
export type TextFieldIconProps = {
|
|
4385
|
+
/** A lucide name, drawn at the start of the field. */
|
|
4386
|
+
leadingIcon?: string | null;
|
|
4387
|
+
/** A lucide name, drawn at the end of the field. */
|
|
4388
|
+
trailingIcon?: string | null;
|
|
4389
|
+
/** Default `${testID}-leading-icon`. */
|
|
4390
|
+
leadingTestID?: string;
|
|
4391
|
+
/** Default `${testID}-trailing-icon`. */
|
|
4392
|
+
trailingTestID?: string;
|
|
4393
|
+
} & (
|
|
4394
|
+
| { onTrailingPress?: undefined; trailingAccessibilityLabel?: string }
|
|
4395
|
+
| {
|
|
4396
|
+
/** Makes the trailing icon a button, disabled with the field. */
|
|
4397
|
+
onTrailingPress: () => void;
|
|
4398
|
+
/** Required: the icon is the button's only content. */
|
|
4399
|
+
trailingAccessibilityLabel: string;
|
|
4400
|
+
}
|
|
4401
|
+
);
|
|
4402
|
+
|
|
4379
4403
|
/**
|
|
4380
4404
|
* Themed text-entry field (Design > Components > Inputs). Every TextInput prop
|
|
4381
|
-
* works.
|
|
4405
|
+
* works. An icon goes inside it through `leadingIcon` / `trailingIcon`, never
|
|
4406
|
+
* in a bordered row around it.
|
|
4382
4407
|
*
|
|
4383
4408
|
* @example
|
|
4384
4409
|
* <TextField label="Name" value={name} onChangeText={setName} placeholder="Your name" />
|
|
4410
|
+
* <TextField label="Pickup" value={address} onChangeText={setAddress} leadingIcon="map-pin" />
|
|
4385
4411
|
*/
|
|
4386
|
-
export const TextField: (props: FormControlProps & FieldRequiredProps & FormFieldOverrides & {
|
|
4412
|
+
export const TextField: (props: FormControlProps & FieldRequiredProps & FormFieldOverrides & TextFieldIconProps & {
|
|
4387
4413
|
multiline?: boolean;
|
|
4414
|
+
/** The input; with icon props, its flex, margin, width and position keys go to the box. */
|
|
4388
4415
|
style?: any;
|
|
4389
|
-
/** The box around a labelled field. */
|
|
4416
|
+
/** The box around a labelled field, or around the field and its icons. */
|
|
4390
4417
|
containerStyle?: any;
|
|
4391
4418
|
[prop: string]: any;
|
|
4392
4419
|
}) => any;
|
|
@@ -4707,7 +4734,7 @@ export const SearchField: (props: FormControlProps & {
|
|
|
4707
4734
|
placeholder?: string;
|
|
4708
4735
|
/** Default "Clear search". */
|
|
4709
4736
|
clearAccessibilityLabel?: string;
|
|
4710
|
-
/** The input. */
|
|
4737
|
+
/** The input; its flex, margin, width and position keys go to the box around it. */
|
|
4711
4738
|
style?: any;
|
|
4712
4739
|
/** The box around it; use it for `flex: 1` in a toolbar. */
|
|
4713
4740
|
containerStyle?: any;
|
package/dist/linter.cjs
CHANGED
|
@@ -1563,6 +1563,160 @@ function _fullWidthMarginRules(source) {
|
|
|
1563
1563
|
return findings;
|
|
1564
1564
|
}
|
|
1565
1565
|
|
|
1566
|
+
// sc-12232 — a field inside a bordered row draws two borders.
|
|
1567
|
+
// A warning: refs resolve by key name, and a bordered column is fine.
|
|
1568
|
+
const _FIELD_TAG_NAMES = ["TextField", "SearchField"];
|
|
1569
|
+
const _JSX_TOKEN_RE = /<(\/?)([A-Z][\w.]*)?/g;
|
|
1570
|
+
const _STYLE_ATTR_RE = /\bstyle\s*=\s*\{/;
|
|
1571
|
+
const _STYLE_REF_RE = /\b[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)/g;
|
|
1572
|
+
const _STYLE_ENTRY_RE = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*:\s*\{/g;
|
|
1573
|
+
const _BORDER_WIDTH_KEY_RE = /\bborderWidth\s*:/g;
|
|
1574
|
+
const _ROW_KEY_RE = /\bflexDirection\s*:\s*(["'`])row(?:-reverse)?\1/;
|
|
1575
|
+
const _UNNAMED_PRESS_RE = /\bonTrailingPress\s*=/;
|
|
1576
|
+
const _PRESS_NAME_RE = /\btrailingAccessibilityLabel\s*=|\{\s*\.\.\./;
|
|
1577
|
+
|
|
1578
|
+
// One pass over the source: the first `key: {` of each name, by offset.
|
|
1579
|
+
function _styleEntryOffsets(structure) {
|
|
1580
|
+
const offsets = new Map();
|
|
1581
|
+
_STYLE_ENTRY_RE.lastIndex = 0;
|
|
1582
|
+
let entry;
|
|
1583
|
+
while ((entry = _STYLE_ENTRY_RE.exec(structure))) {
|
|
1584
|
+
if (!offsets.has(entry[1])) offsets.set(entry[1], entry.index + entry[0].length);
|
|
1585
|
+
}
|
|
1586
|
+
return offsets;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// The object literals a style expression spans, plus the entries it names.
|
|
1590
|
+
function _styleObjectSpans(scan, [from, to]) {
|
|
1591
|
+
const { structure } = scan;
|
|
1592
|
+
const spans = [];
|
|
1593
|
+
for (let i = from + 1; i < to - 1; i++) {
|
|
1594
|
+
if (structure[i] !== "{") continue;
|
|
1595
|
+
const span = _enclosingObject(structure, i + 1);
|
|
1596
|
+
if (!span) continue;
|
|
1597
|
+
spans.push(span);
|
|
1598
|
+
i = span[1] - 1;
|
|
1599
|
+
}
|
|
1600
|
+
if (!scan.entries) scan.entries = _styleEntryOffsets(structure);
|
|
1601
|
+
const keys = new Set();
|
|
1602
|
+
_STYLE_REF_RE.lastIndex = 0;
|
|
1603
|
+
const refs = structure.slice(from, to);
|
|
1604
|
+
let ref;
|
|
1605
|
+
while ((ref = _STYLE_REF_RE.exec(refs))) keys.add(ref[1]);
|
|
1606
|
+
for (const key of keys) {
|
|
1607
|
+
const at = scan.entries.get(key);
|
|
1608
|
+
const span = at === undefined ? null : _enclosingObject(structure, at);
|
|
1609
|
+
if (span) spans.push(span);
|
|
1610
|
+
}
|
|
1611
|
+
return spans;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function _isBorderedRow(scan, tag) {
|
|
1615
|
+
const { code, structure } = scan;
|
|
1616
|
+
const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
|
|
1617
|
+
const style = _STYLE_ATTR_RE.exec(attrs);
|
|
1618
|
+
if (!style) return false;
|
|
1619
|
+
const open = tag.attrs[0] + style.index + style[0].length - 1;
|
|
1620
|
+
const inner = _bracedSpan(structure, open);
|
|
1621
|
+
if (inner === null) return false;
|
|
1622
|
+
let row = false;
|
|
1623
|
+
let bordered = false;
|
|
1624
|
+
// `inner` excludes its two braces.
|
|
1625
|
+
for (const span of _styleObjectSpans(scan, [open, open + inner.length + 2])) {
|
|
1626
|
+
const keys = _ownKeysMask(structure, span);
|
|
1627
|
+
if (_ROW_KEY_RE.test(_ownKeysMask(code, span))) row = true;
|
|
1628
|
+
_BORDER_WIDTH_KEY_RE.lastIndex = 0;
|
|
1629
|
+
let key;
|
|
1630
|
+
while ((key = _BORDER_WIDTH_KEY_RE.exec(keys))) {
|
|
1631
|
+
const valueAt = span[0] + key.index + key[0].length;
|
|
1632
|
+
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) bordered = true;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return row && bordered;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function _selfClosing(structure, end) {
|
|
1639
|
+
let i = end - 1;
|
|
1640
|
+
while (i >= 0 && /\s/.test(structure[i])) i--;
|
|
1641
|
+
return structure[i] === "/";
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function _fieldInBorderedRowRules(source) {
|
|
1645
|
+
const findings = [];
|
|
1646
|
+
const scan = {
|
|
1647
|
+
code: _stripNonCode(source, { keepStrings: true }),
|
|
1648
|
+
structure: _stripNonCode(source),
|
|
1649
|
+
entries: null,
|
|
1650
|
+
};
|
|
1651
|
+
const { code, structure } = scan;
|
|
1652
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1653
|
+
// Tokens arrive in source order, so one running count serves every finding.
|
|
1654
|
+
let line = 1;
|
|
1655
|
+
let counted = 0;
|
|
1656
|
+
const lineOf = (at) => {
|
|
1657
|
+
for (; counted < at; counted++) if (code[counted] === "\n") line++;
|
|
1658
|
+
return line;
|
|
1659
|
+
};
|
|
1660
|
+
const report = (rule, at, label) => {
|
|
1661
|
+
const n = lineOf(at);
|
|
1662
|
+
findings.push({
|
|
1663
|
+
rule,
|
|
1664
|
+
severity: "warning",
|
|
1665
|
+
label,
|
|
1666
|
+
line: n,
|
|
1667
|
+
snippet: (sourceLines[n - 1] || "").trim().slice(0, 200),
|
|
1668
|
+
});
|
|
1669
|
+
};
|
|
1670
|
+
const open = [];
|
|
1671
|
+
_JSX_TOKEN_RE.lastIndex = 0;
|
|
1672
|
+
let token;
|
|
1673
|
+
while ((token = _JSX_TOKEN_RE.exec(structure))) {
|
|
1674
|
+
const [, closing, name] = token;
|
|
1675
|
+
if (!name) {
|
|
1676
|
+
// A fragment, `<>` or `</>`; any other `<` is a comparison.
|
|
1677
|
+
if (structure[token.index + 1 + closing.length] !== ">") continue;
|
|
1678
|
+
if (closing) open.pop();
|
|
1679
|
+
else open.push(null);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (closing) {
|
|
1683
|
+
const at = open.map((tag) => tag && tag.name).lastIndexOf(name);
|
|
1684
|
+
if (at >= 0) open.length = at;
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
const end = _tagEnd(structure, token.index);
|
|
1688
|
+
if (end === -1) break;
|
|
1689
|
+
const tag = { name, attrs: [token.index + token[0].length, end], borderedRow: undefined };
|
|
1690
|
+
if (_FIELD_TAG_NAMES.includes(name)) {
|
|
1691
|
+
const parent = open[open.length - 1];
|
|
1692
|
+
if (parent && parent.borderedRow === undefined) parent.borderedRow = _isBorderedRow(scan, parent);
|
|
1693
|
+
if (parent && parent.borderedRow) {
|
|
1694
|
+
report(
|
|
1695
|
+
"field-in-bordered-row",
|
|
1696
|
+
token.index,
|
|
1697
|
+
`<${name}> draws its own border, so the bordered row around it ` +
|
|
1698
|
+
`shows a box inside a box. Put the icon in the field with ` +
|
|
1699
|
+
`leadingIcon / trailingIcon (onTrailingPress makes it a button) ` +
|
|
1700
|
+
`and drop the row's border.`,
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
|
|
1704
|
+
if (_UNNAMED_PRESS_RE.test(attrs) && !_PRESS_NAME_RE.test(attrs)) {
|
|
1705
|
+
report(
|
|
1706
|
+
"field-trailing-press-unnamed",
|
|
1707
|
+
token.index,
|
|
1708
|
+
`onTrailingPress makes the trailing icon a button with no name, so ` +
|
|
1709
|
+
`a screen reader says only "button". Give it a translated ` +
|
|
1710
|
+
`trailingAccessibilityLabel.`,
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (!_selfClosing(structure, end)) open.push(tag);
|
|
1715
|
+
_JSX_TOKEN_RE.lastIndex = end + 1;
|
|
1716
|
+
}
|
|
1717
|
+
return findings;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1566
1720
|
// sc-4913 — a measured frame INCLUDES the element's own padding, so cells sized
|
|
1567
1721
|
// from it overflow the content box and the last one wraps into an empty column.
|
|
1568
1722
|
// Mirror of linter.js (see there for the full rationale).
|
|
@@ -2188,6 +2342,8 @@ function lintSource(source, options) {
|
|
|
2188
2342
|
findings.push(..._flexBasisPercentRules(source));
|
|
2189
2343
|
// sc-11277 — soft warning: a full-width box with a side margin is clipped.
|
|
2190
2344
|
findings.push(..._fullWidthMarginRules(source));
|
|
2345
|
+
// sc-12232 — soft warning: a field inside a bordered row draws two borders.
|
|
2346
|
+
findings.push(..._fieldInBorderedRowRules(source));
|
|
2191
2347
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2192
2348
|
// padding wraps the last grid column into an empty one.
|
|
2193
2349
|
findings.push(..._measuredPaddingRules(source));
|
package/dist/linter.js
CHANGED
|
@@ -1604,6 +1604,160 @@ function _fullWidthMarginRules(source) {
|
|
|
1604
1604
|
return findings;
|
|
1605
1605
|
}
|
|
1606
1606
|
|
|
1607
|
+
// sc-12232 — a field inside a bordered row draws two borders.
|
|
1608
|
+
// A warning: refs resolve by key name, and a bordered column is fine.
|
|
1609
|
+
const _FIELD_TAG_NAMES = ["TextField", "SearchField"];
|
|
1610
|
+
const _JSX_TOKEN_RE = /<(\/?)([A-Z][\w.]*)?/g;
|
|
1611
|
+
const _STYLE_ATTR_RE = /\bstyle\s*=\s*\{/;
|
|
1612
|
+
const _STYLE_REF_RE = /\b[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)/g;
|
|
1613
|
+
const _STYLE_ENTRY_RE = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*:\s*\{/g;
|
|
1614
|
+
const _BORDER_WIDTH_KEY_RE = /\bborderWidth\s*:/g;
|
|
1615
|
+
const _ROW_KEY_RE = /\bflexDirection\s*:\s*(["'`])row(?:-reverse)?\1/;
|
|
1616
|
+
const _UNNAMED_PRESS_RE = /\bonTrailingPress\s*=/;
|
|
1617
|
+
const _PRESS_NAME_RE = /\btrailingAccessibilityLabel\s*=|\{\s*\.\.\./;
|
|
1618
|
+
|
|
1619
|
+
// One pass over the source: the first `key: {` of each name, by offset.
|
|
1620
|
+
function _styleEntryOffsets(structure) {
|
|
1621
|
+
const offsets = new Map();
|
|
1622
|
+
_STYLE_ENTRY_RE.lastIndex = 0;
|
|
1623
|
+
let entry;
|
|
1624
|
+
while ((entry = _STYLE_ENTRY_RE.exec(structure))) {
|
|
1625
|
+
if (!offsets.has(entry[1])) offsets.set(entry[1], entry.index + entry[0].length);
|
|
1626
|
+
}
|
|
1627
|
+
return offsets;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// The object literals a style expression spans, plus the entries it names.
|
|
1631
|
+
function _styleObjectSpans(scan, [from, to]) {
|
|
1632
|
+
const { structure } = scan;
|
|
1633
|
+
const spans = [];
|
|
1634
|
+
for (let i = from + 1; i < to - 1; i++) {
|
|
1635
|
+
if (structure[i] !== "{") continue;
|
|
1636
|
+
const span = _enclosingObject(structure, i + 1);
|
|
1637
|
+
if (!span) continue;
|
|
1638
|
+
spans.push(span);
|
|
1639
|
+
i = span[1] - 1;
|
|
1640
|
+
}
|
|
1641
|
+
if (!scan.entries) scan.entries = _styleEntryOffsets(structure);
|
|
1642
|
+
const keys = new Set();
|
|
1643
|
+
_STYLE_REF_RE.lastIndex = 0;
|
|
1644
|
+
const refs = structure.slice(from, to);
|
|
1645
|
+
let ref;
|
|
1646
|
+
while ((ref = _STYLE_REF_RE.exec(refs))) keys.add(ref[1]);
|
|
1647
|
+
for (const key of keys) {
|
|
1648
|
+
const at = scan.entries.get(key);
|
|
1649
|
+
const span = at === undefined ? null : _enclosingObject(structure, at);
|
|
1650
|
+
if (span) spans.push(span);
|
|
1651
|
+
}
|
|
1652
|
+
return spans;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function _isBorderedRow(scan, tag) {
|
|
1656
|
+
const { code, structure } = scan;
|
|
1657
|
+
const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
|
|
1658
|
+
const style = _STYLE_ATTR_RE.exec(attrs);
|
|
1659
|
+
if (!style) return false;
|
|
1660
|
+
const open = tag.attrs[0] + style.index + style[0].length - 1;
|
|
1661
|
+
const inner = _bracedSpan(structure, open);
|
|
1662
|
+
if (inner === null) return false;
|
|
1663
|
+
let row = false;
|
|
1664
|
+
let bordered = false;
|
|
1665
|
+
// `inner` excludes its two braces.
|
|
1666
|
+
for (const span of _styleObjectSpans(scan, [open, open + inner.length + 2])) {
|
|
1667
|
+
const keys = _ownKeysMask(structure, span);
|
|
1668
|
+
if (_ROW_KEY_RE.test(_ownKeysMask(code, span))) row = true;
|
|
1669
|
+
_BORDER_WIDTH_KEY_RE.lastIndex = 0;
|
|
1670
|
+
let key;
|
|
1671
|
+
while ((key = _BORDER_WIDTH_KEY_RE.exec(keys))) {
|
|
1672
|
+
const valueAt = span[0] + key.index + key[0].length;
|
|
1673
|
+
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) bordered = true;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return row && bordered;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function _selfClosing(structure, end) {
|
|
1680
|
+
let i = end - 1;
|
|
1681
|
+
while (i >= 0 && /\s/.test(structure[i])) i--;
|
|
1682
|
+
return structure[i] === "/";
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function _fieldInBorderedRowRules(source) {
|
|
1686
|
+
const findings = [];
|
|
1687
|
+
const scan = {
|
|
1688
|
+
code: _stripNonCode(source, { keepStrings: true }),
|
|
1689
|
+
structure: _stripNonCode(source),
|
|
1690
|
+
entries: null,
|
|
1691
|
+
};
|
|
1692
|
+
const { code, structure } = scan;
|
|
1693
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1694
|
+
// Tokens arrive in source order, so one running count serves every finding.
|
|
1695
|
+
let line = 1;
|
|
1696
|
+
let counted = 0;
|
|
1697
|
+
const lineOf = (at) => {
|
|
1698
|
+
for (; counted < at; counted++) if (code[counted] === "\n") line++;
|
|
1699
|
+
return line;
|
|
1700
|
+
};
|
|
1701
|
+
const report = (rule, at, label) => {
|
|
1702
|
+
const n = lineOf(at);
|
|
1703
|
+
findings.push({
|
|
1704
|
+
rule,
|
|
1705
|
+
severity: "warning",
|
|
1706
|
+
label,
|
|
1707
|
+
line: n,
|
|
1708
|
+
snippet: (sourceLines[n - 1] || "").trim().slice(0, 200),
|
|
1709
|
+
});
|
|
1710
|
+
};
|
|
1711
|
+
const open = [];
|
|
1712
|
+
_JSX_TOKEN_RE.lastIndex = 0;
|
|
1713
|
+
let token;
|
|
1714
|
+
while ((token = _JSX_TOKEN_RE.exec(structure))) {
|
|
1715
|
+
const [, closing, name] = token;
|
|
1716
|
+
if (!name) {
|
|
1717
|
+
// A fragment, `<>` or `</>`; any other `<` is a comparison.
|
|
1718
|
+
if (structure[token.index + 1 + closing.length] !== ">") continue;
|
|
1719
|
+
if (closing) open.pop();
|
|
1720
|
+
else open.push(null);
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
if (closing) {
|
|
1724
|
+
const at = open.map((tag) => tag && tag.name).lastIndexOf(name);
|
|
1725
|
+
if (at >= 0) open.length = at;
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
const end = _tagEnd(structure, token.index);
|
|
1729
|
+
if (end === -1) break;
|
|
1730
|
+
const tag = { name, attrs: [token.index + token[0].length, end], borderedRow: undefined };
|
|
1731
|
+
if (_FIELD_TAG_NAMES.includes(name)) {
|
|
1732
|
+
const parent = open[open.length - 1];
|
|
1733
|
+
if (parent && parent.borderedRow === undefined) parent.borderedRow = _isBorderedRow(scan, parent);
|
|
1734
|
+
if (parent && parent.borderedRow) {
|
|
1735
|
+
report(
|
|
1736
|
+
"field-in-bordered-row",
|
|
1737
|
+
token.index,
|
|
1738
|
+
`<${name}> draws its own border, so the bordered row around it ` +
|
|
1739
|
+
`shows a box inside a box. Put the icon in the field with ` +
|
|
1740
|
+
`leadingIcon / trailingIcon (onTrailingPress makes it a button) ` +
|
|
1741
|
+
`and drop the row's border.`,
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
|
|
1745
|
+
if (_UNNAMED_PRESS_RE.test(attrs) && !_PRESS_NAME_RE.test(attrs)) {
|
|
1746
|
+
report(
|
|
1747
|
+
"field-trailing-press-unnamed",
|
|
1748
|
+
token.index,
|
|
1749
|
+
`onTrailingPress makes the trailing icon a button with no name, so ` +
|
|
1750
|
+
`a screen reader says only "button". Give it a translated ` +
|
|
1751
|
+
`trailingAccessibilityLabel.`,
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (!_selfClosing(structure, end)) open.push(tag);
|
|
1756
|
+
_JSX_TOKEN_RE.lastIndex = end + 1;
|
|
1757
|
+
}
|
|
1758
|
+
return findings;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1607
1761
|
// sc-4913 — a measured frame INCLUDES the element's own padding.
|
|
1608
1762
|
//
|
|
1609
1763
|
// `onLayout` reports the frame width, and padding sits inside that frame. A
|
|
@@ -2256,6 +2410,8 @@ export function lintSource(source, options) {
|
|
|
2256
2410
|
findings.push(..._flexBasisPercentRules(source));
|
|
2257
2411
|
// sc-11277 — soft warning: a full-width box with a side margin is clipped.
|
|
2258
2412
|
findings.push(..._fullWidthMarginRules(source));
|
|
2413
|
+
// sc-12232 — soft warning: a field inside a bordered row draws two borders.
|
|
2414
|
+
findings.push(..._fieldInBorderedRowRules(source));
|
|
2259
2415
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2260
2416
|
// padding wraps the last grid column into an empty one.
|
|
2261
2417
|
findings.push(..._measuredPaddingRules(source));
|
|
@@ -55,19 +55,17 @@ export function resolveSearchFieldTokens(theme, own) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* What fills `<TextField>`'s two icon slots. At the end, the clear control
|
|
59
59
|
* takes the glyph's slot while there is text, so the two never overlap.
|
|
60
60
|
*
|
|
61
61
|
* @param {string} iconPosition — a resolved SEARCH_ICON_POSITIONS value.
|
|
62
62
|
* @param {boolean} clearable — the field has text and is enabled.
|
|
63
|
-
* @returns {{ glyph: "
|
|
63
|
+
* @returns {{ leading: "glyph"|null, trailing: "glyph"|"clear"|null }}
|
|
64
64
|
*/
|
|
65
65
|
export function searchFieldLayout(iconPosition, clearable) {
|
|
66
|
-
const glyphAtEnd = iconPosition === "end" && !clearable;
|
|
67
66
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
padEnd: clearable || iconPosition === "end",
|
|
67
|
+
leading: iconPosition === "start" ? "glyph" : null,
|
|
68
|
+
trailing: clearable ? "clear" : iconPosition === "end" ? "glyph" : null,
|
|
71
69
|
};
|
|
72
70
|
}
|
|
73
71
|
|
|
@@ -1,41 +1,16 @@
|
|
|
1
1
|
// The ONE implementation behind the `<SearchField>` SDK primitive. It is
|
|
2
|
-
// `<TextField>` with a search glyph and a clear control
|
|
3
|
-
// field and takes the same props, plus the theme's `search`
|
|
4
|
-
// bindings (`search-field.js` / `search-field.native.js`)
|
|
5
|
-
// the primitives come from.
|
|
2
|
+
// `<TextField>` with a search glyph and a clear control in its icon slots, so
|
|
3
|
+
// it wears the same field and takes the same props, plus the theme's `search`
|
|
4
|
+
// look; the platform bindings (`search-field.js` / `search-field.native.js`)
|
|
5
|
+
// differ only in where the primitives come from.
|
|
6
6
|
|
|
7
7
|
import React from "react";
|
|
8
8
|
import { useHostTheme } from "./hooks.js";
|
|
9
9
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
10
|
-
import { Icon } from "./icon.js";
|
|
11
|
-
import { resolveFormControlTokens } from "./form-control-tokens.js";
|
|
12
10
|
import { resolveSearchFieldTokens, searchFieldLayout } from "./search-field-tokens.js";
|
|
13
|
-
import {
|
|
14
|
-
errorMessage,
|
|
15
|
-
hasNote,
|
|
16
|
-
isShown,
|
|
17
|
-
shareRef,
|
|
18
|
-
spokenLabel,
|
|
19
|
-
} from "./form-control-logic.js";
|
|
20
|
-
import { makeFieldFrame } from "./form-control-view.js";
|
|
11
|
+
import { shareRef, spokenLabel } from "./form-control-logic.js";
|
|
21
12
|
|
|
22
13
|
const h = React.createElement;
|
|
23
|
-
const GLYPH_SIZE = 18;
|
|
24
|
-
// The glyph and the clear control each own a strip this wide inside the field.
|
|
25
|
-
const ADORNMENT_WIDTH = 40;
|
|
26
|
-
// A real target: the web host ignores hitSlop, so size carries it.
|
|
27
|
-
const CLEAR_MIN_SIZE = 36;
|
|
28
|
-
|
|
29
|
-
function adornmentSlot(side) {
|
|
30
|
-
return {
|
|
31
|
-
position: "absolute",
|
|
32
|
-
top: 0,
|
|
33
|
-
bottom: 0,
|
|
34
|
-
alignItems: "center",
|
|
35
|
-
justifyContent: "center",
|
|
36
|
-
...(side === "start" ? { left: 0 } : { right: 0 }),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
14
|
|
|
40
15
|
/**
|
|
41
16
|
* Bind the shared search field to one platform's primitives.
|
|
@@ -43,8 +18,7 @@ function adornmentSlot(side) {
|
|
|
43
18
|
* @param {{View: any, Text: any, Pressable: any, TextField: any}} rn
|
|
44
19
|
*/
|
|
45
20
|
export function makeSearchField(rn) {
|
|
46
|
-
const {
|
|
47
|
-
const FieldFrame = makeFieldFrame(rn);
|
|
21
|
+
const { TextField } = rn;
|
|
48
22
|
|
|
49
23
|
const SearchField = React.forwardRef(function SearchField(props, ref) {
|
|
50
24
|
const {
|
|
@@ -54,113 +28,59 @@ export function makeSearchField(rn) {
|
|
|
54
28
|
placeholder = "Search",
|
|
55
29
|
clearAccessibilityLabel = "Clear search",
|
|
56
30
|
label,
|
|
57
|
-
helper,
|
|
58
|
-
error,
|
|
59
31
|
disabled = false,
|
|
60
32
|
accessibilityLabel,
|
|
61
33
|
iconPosition,
|
|
62
|
-
style,
|
|
63
|
-
containerStyle,
|
|
64
34
|
testID,
|
|
65
35
|
...rest
|
|
66
36
|
} = props;
|
|
67
37
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
68
|
-
const t = resolveFormControlTokens(theme);
|
|
69
38
|
const search = resolveSearchFieldTokens(theme, { iconPosition });
|
|
70
39
|
const inputRef = React.useRef(null);
|
|
71
40
|
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
72
|
-
const noteId = React.useId();
|
|
73
|
-
const focusInput = () => {
|
|
74
|
-
const input = inputRef.current;
|
|
75
|
-
if (input && typeof input.focus === "function") input.focus();
|
|
76
|
-
};
|
|
77
41
|
const hasText = typeof value === "string" && value.length > 0;
|
|
78
|
-
const
|
|
79
|
-
const
|
|
42
|
+
const layout = searchFieldLayout(search.iconPosition, hasText && !disabled);
|
|
43
|
+
const slotTestID = (suffix) => (testID ? `${testID}-${suffix}` : undefined);
|
|
80
44
|
const clear = () => {
|
|
81
45
|
if (typeof onChangeText === "function") onChangeText("");
|
|
82
46
|
if (typeof onClear === "function") onClear();
|
|
83
|
-
|
|
47
|
+
const input = inputRef.current;
|
|
48
|
+
if (input && typeof input.focus === "function") input.focus();
|
|
84
49
|
};
|
|
85
50
|
|
|
86
|
-
return h(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
...search.field,
|
|
120
|
-
...rest,
|
|
121
|
-
style: [
|
|
122
|
-
layout.padStart ? { paddingLeft: ADORNMENT_WIDTH } : null,
|
|
123
|
-
layout.padEnd ? { paddingRight: ADORNMENT_WIDTH } : null,
|
|
124
|
-
style,
|
|
125
|
-
],
|
|
126
|
-
}),
|
|
127
|
-
layout.glyph
|
|
128
|
-
? h(
|
|
129
|
-
View,
|
|
130
|
-
{
|
|
131
|
-
testID: testID ? `${testID}-icon` : undefined,
|
|
132
|
-
style: {
|
|
133
|
-
...adornmentSlot(layout.glyph),
|
|
134
|
-
width: ADORNMENT_WIDTH,
|
|
135
|
-
pointerEvents: "none",
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
h(Icon, { name: "search", size: GLYPH_SIZE, color: t.placeholderColor }),
|
|
139
|
-
)
|
|
140
|
-
: null,
|
|
141
|
-
clearable
|
|
142
|
-
? h(
|
|
143
|
-
Pressable,
|
|
144
|
-
{
|
|
145
|
-
onPress: clear,
|
|
146
|
-
accessibilityRole: "button",
|
|
147
|
-
accessibilityLabel: clearAccessibilityLabel,
|
|
148
|
-
testID: testID ? `${testID}-clear` : undefined,
|
|
149
|
-
style: ({ pressed }) => [
|
|
150
|
-
{
|
|
151
|
-
...adornmentSlot("end"),
|
|
152
|
-
right: 2,
|
|
153
|
-
minWidth: CLEAR_MIN_SIZE,
|
|
154
|
-
minHeight: CLEAR_MIN_SIZE,
|
|
155
|
-
},
|
|
156
|
-
pressed ? { opacity: 0.6 } : null,
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
h(Icon, { name: "x", size: GLYPH_SIZE, color: t.placeholderColor }),
|
|
160
|
-
)
|
|
161
|
-
: null,
|
|
162
|
-
),
|
|
163
|
-
);
|
|
51
|
+
return h(TextField, {
|
|
52
|
+
ref: setRef,
|
|
53
|
+
value,
|
|
54
|
+
onChangeText,
|
|
55
|
+
placeholder,
|
|
56
|
+
label,
|
|
57
|
+
disabled,
|
|
58
|
+
// `role` names it on the web; `accessibilityRole` on a device.
|
|
59
|
+
role: "searchbox",
|
|
60
|
+
accessibilityRole: "search",
|
|
61
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label) || placeholder,
|
|
62
|
+
enterKeyHint: "search",
|
|
63
|
+
autoCapitalize: "none",
|
|
64
|
+
autoCorrect: false,
|
|
65
|
+
testID,
|
|
66
|
+
// The field's own props beat the theme's search look.
|
|
67
|
+
...search.field,
|
|
68
|
+
...rest,
|
|
69
|
+
// Both slots always passed, so the clear control never rebuilds the input.
|
|
70
|
+
leadingIcon: layout.leading === "glyph" ? "search" : null,
|
|
71
|
+
leadingTestID: slotTestID("icon"),
|
|
72
|
+
...(layout.trailing === "clear"
|
|
73
|
+
? {
|
|
74
|
+
trailingIcon: "x",
|
|
75
|
+
onTrailingPress: clear,
|
|
76
|
+
trailingAccessibilityLabel: clearAccessibilityLabel,
|
|
77
|
+
trailingTestID: slotTestID("clear"),
|
|
78
|
+
}
|
|
79
|
+
: {
|
|
80
|
+
trailingIcon: layout.trailing === "glyph" ? "search" : null,
|
|
81
|
+
trailingTestID: slotTestID("icon"),
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
164
84
|
});
|
|
165
85
|
|
|
166
86
|
SearchField.displayName = "SearchField";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Where `<TextField>`'s icons sit inside its own border, and which of the
|
|
2
|
+
// caller's style keys move to the box that holds the field and its icons.
|
|
3
|
+
// Platform-free, so both hosts lay an iconned field out from one answer.
|
|
4
|
+
|
|
5
|
+
import { flattenStyle } from "./flatten-style.js";
|
|
6
|
+
|
|
7
|
+
// Each icon owns a strip this wide inside the field's border.
|
|
8
|
+
export const ADORNMENT_WIDTH = 40;
|
|
9
|
+
export const ADORNMENT_GLYPH_SIZE = 18;
|
|
10
|
+
// A real target: the web host ignores hitSlop, so size carries it.
|
|
11
|
+
export const ADORNMENT_PRESS_MIN_SIZE = 36;
|
|
12
|
+
|
|
13
|
+
export const ADORNMENT_PROPS = Object.freeze(["leadingIcon", "trailingIcon"]);
|
|
14
|
+
|
|
15
|
+
// Where the field sits among its siblings, not how it looks. The box takes
|
|
16
|
+
// the input's place in the tree, so these must move with it.
|
|
17
|
+
export const FIELD_PLACEMENT_STYLE_KEYS = Object.freeze([
|
|
18
|
+
"margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
|
|
19
|
+
"marginHorizontal", "marginVertical", "marginStart", "marginEnd",
|
|
20
|
+
"marginBlock", "marginBlockStart", "marginBlockEnd",
|
|
21
|
+
"marginInline", "marginInlineStart", "marginInlineEnd",
|
|
22
|
+
"flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf",
|
|
23
|
+
"width", "minWidth", "maxWidth",
|
|
24
|
+
"position", "top", "right", "bottom", "left", "start", "end", "zIndex",
|
|
25
|
+
"display",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// A percentage resolves against the parent, which is now the box.
|
|
29
|
+
const HEIGHT_KEYS = Object.freeze(["height", "minHeight", "maxHeight"]);
|
|
30
|
+
|
|
31
|
+
function isPercent(value) {
|
|
32
|
+
return typeof value === "string" && value.trim().endsWith("%");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the field draws the box its icons sit in. Keyed on the props being
|
|
37
|
+
* passed at all, not on their value: an icon that comes and goes with the
|
|
38
|
+
* text must not rebuild the input under the caret.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} props — the field's props.
|
|
41
|
+
*/
|
|
42
|
+
export function hasAdornmentSlots(props) {
|
|
43
|
+
return ADORNMENT_PROPS.some((name) => Object.prototype.hasOwnProperty.call(props, name));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An icon prop that draws a glyph: a non-empty lucide name. */
|
|
47
|
+
export function isIconName(value) {
|
|
48
|
+
return typeof value === "string" && value.trim() !== "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split a caller's `style` between the box and the input inside it. `fill` is
|
|
53
|
+
* whether a height moved, which the input must then fill.
|
|
54
|
+
*
|
|
55
|
+
* @param {object|Array|null|undefined|false} style
|
|
56
|
+
* @returns {{ box: object, field: object, fill: boolean }}
|
|
57
|
+
*/
|
|
58
|
+
export function splitFieldStyle(style) {
|
|
59
|
+
const box = {};
|
|
60
|
+
const field = {};
|
|
61
|
+
let fill = false;
|
|
62
|
+
for (const [key, value] of Object.entries(flattenStyle(style))) {
|
|
63
|
+
const percentHeight = HEIGHT_KEYS.includes(key) && isPercent(value);
|
|
64
|
+
if (percentHeight) fill = true;
|
|
65
|
+
(FIELD_PLACEMENT_STYLE_KEYS.includes(key) || percentHeight ? box : field)[key] = value;
|
|
66
|
+
}
|
|
67
|
+
return { box, field, fill };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The strip one icon is drawn in. A pressable icon is a 36px target inset from
|
|
72
|
+
* the edge; a plain glyph fills its strip and lets presses through to the
|
|
73
|
+
* field. A paragraph field keeps its icons beside its first line.
|
|
74
|
+
*
|
|
75
|
+
* @param {"start"|"end"} side
|
|
76
|
+
* @param {{ pressable?: boolean, multiline?: boolean, rowHeight: number }} opts
|
|
77
|
+
*/
|
|
78
|
+
export function adornmentSlotStyle(side, { pressable = false, multiline = false, rowHeight }) {
|
|
79
|
+
return {
|
|
80
|
+
position: "absolute",
|
|
81
|
+
top: 0,
|
|
82
|
+
...(multiline ? { height: rowHeight } : { bottom: 0 }),
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
justifyContent: "center",
|
|
85
|
+
...(pressable
|
|
86
|
+
? {
|
|
87
|
+
[side === "start" ? "left" : "right"]: 2,
|
|
88
|
+
minWidth: ADORNMENT_PRESS_MIN_SIZE,
|
|
89
|
+
minHeight: ADORNMENT_PRESS_MIN_SIZE,
|
|
90
|
+
}
|
|
91
|
+
: { [side === "start" ? "left" : "right"]: 0, width: ADORNMENT_WIDTH, pointerEvents: "none" }),
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/text-field-view.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { useHostSharedText, useHostTheme } from "./hooks.js";
|
|
7
7
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import { Icon } from "./icon.js";
|
|
8
9
|
import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
|
|
9
10
|
import {
|
|
10
11
|
FIELD_OUTLINE_OFF,
|
|
@@ -20,20 +21,60 @@ import {
|
|
|
20
21
|
shareRef,
|
|
21
22
|
spokenLabel,
|
|
22
23
|
} from "./form-control-logic.js";
|
|
24
|
+
import {
|
|
25
|
+
ADORNMENT_GLYPH_SIZE,
|
|
26
|
+
ADORNMENT_WIDTH,
|
|
27
|
+
adornmentSlotStyle,
|
|
28
|
+
hasAdornmentSlots,
|
|
29
|
+
isIconName,
|
|
30
|
+
splitFieldStyle,
|
|
31
|
+
} from "./text-field-adornment.js";
|
|
23
32
|
import { makeFieldFrame } from "./form-control-view.js";
|
|
24
33
|
|
|
34
|
+
const h = React.createElement;
|
|
35
|
+
|
|
25
36
|
// A paragraph field grows to a ceiling, then scrolls inside itself.
|
|
26
37
|
const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
|
|
27
38
|
|
|
39
|
+
// Hidden from a screen reader: the field's label already names it.
|
|
40
|
+
const DECORATIVE = {
|
|
41
|
+
"aria-hidden": true,
|
|
42
|
+
accessibilityElementsHidden: true,
|
|
43
|
+
importantForAccessibility: "no-hide-descendants",
|
|
44
|
+
};
|
|
45
|
+
|
|
28
46
|
/**
|
|
29
47
|
* Bind the shared field to one platform's primitives.
|
|
30
48
|
*
|
|
31
49
|
* @param {{TextInput: any, View: any, Text: any, Pressable: any}} rn
|
|
32
50
|
*/
|
|
33
51
|
export function makeTextField(rn) {
|
|
34
|
-
const { TextInput } = rn;
|
|
52
|
+
const { TextInput, View, Pressable } = rn;
|
|
35
53
|
const FieldFrame = makeFieldFrame(rn);
|
|
36
54
|
|
|
55
|
+
function Adornment({ side, icon, onPress, accessibilityLabel, disabled, testID, t, multiline }) {
|
|
56
|
+
const glyph = h(Icon, { name: icon, size: ADORNMENT_GLYPH_SIZE, color: t.placeholderColor });
|
|
57
|
+
const slot = { multiline, rowHeight: t.height };
|
|
58
|
+
if (typeof onPress !== "function") {
|
|
59
|
+
return h(View, { testID, ...DECORATIVE, style: adornmentSlotStyle(side, slot) }, glyph);
|
|
60
|
+
}
|
|
61
|
+
return h(
|
|
62
|
+
Pressable,
|
|
63
|
+
{
|
|
64
|
+
onPress,
|
|
65
|
+
disabled,
|
|
66
|
+
accessibilityRole: "button",
|
|
67
|
+
accessibilityLabel,
|
|
68
|
+
testID,
|
|
69
|
+
style: ({ pressed }) => [
|
|
70
|
+
adornmentSlotStyle(side, { ...slot, pressable: true }),
|
|
71
|
+
pressed ? { opacity: 0.6 } : null,
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
glyph,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
const TextField = React.forwardRef(function TextField(props, ref) {
|
|
38
79
|
const {
|
|
39
80
|
style,
|
|
@@ -48,6 +89,13 @@ export function makeTextField(rn) {
|
|
|
48
89
|
required = false,
|
|
49
90
|
containerStyle,
|
|
50
91
|
accessibilityLabel,
|
|
92
|
+
leadingIcon,
|
|
93
|
+
trailingIcon,
|
|
94
|
+
onTrailingPress,
|
|
95
|
+
trailingAccessibilityLabel,
|
|
96
|
+
leadingTestID,
|
|
97
|
+
trailingTestID,
|
|
98
|
+
testID,
|
|
51
99
|
...others
|
|
52
100
|
} = props;
|
|
53
101
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
@@ -62,13 +110,18 @@ export function makeTextField(rn) {
|
|
|
62
110
|
const t = resolveFormControlTokens(theme, overrides);
|
|
63
111
|
const invalid = isShown(error);
|
|
64
112
|
const framed = needsFrame({ label, helper, error });
|
|
113
|
+
const boxed = hasAdornmentSlots(props);
|
|
114
|
+
const leading = isIconName(leadingIcon);
|
|
115
|
+
const trailing = isIconName(trailingIcon);
|
|
116
|
+
const placed = boxed ? splitFieldStyle(style) : { box: null, field: style, fill: false };
|
|
65
117
|
const inputRef = React.useRef(null);
|
|
66
118
|
// Stable, so the host does not detach and re-attach the node every render.
|
|
67
119
|
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
68
120
|
const noteId = React.useId();
|
|
69
|
-
const input =
|
|
121
|
+
const input = h(TextInput, {
|
|
70
122
|
ref: setRef,
|
|
71
123
|
multiline,
|
|
124
|
+
testID,
|
|
72
125
|
placeholderTextColor: placeholderTextColor || t.placeholderColor,
|
|
73
126
|
accessibilityLabel: spokenLabel(accessibilityLabel, label, required ? requiredWord : null),
|
|
74
127
|
...(disabled ? { editable: false, "aria-disabled": true } : null),
|
|
@@ -91,9 +144,13 @@ export function makeTextField(rn) {
|
|
|
91
144
|
},
|
|
92
145
|
FIELD_OUTLINE_OFF,
|
|
93
146
|
fieldStateStyle(t, { focused, invalid }),
|
|
94
|
-
// The frame dims a
|
|
95
|
-
disabled && !framed ? { opacity: t.disabledOpacity } : null,
|
|
96
|
-
|
|
147
|
+
// The frame, or the icons' box, dims a field as a whole.
|
|
148
|
+
disabled && !framed && !boxed ? { opacity: t.disabledOpacity } : null,
|
|
149
|
+
// Otherwise it keeps its height, centred in a stretched box.
|
|
150
|
+
placed.fill ? { flexGrow: 1 } : null,
|
|
151
|
+
leading ? { paddingLeft: ADORNMENT_WIDTH } : null,
|
|
152
|
+
trailing ? { paddingRight: ADORNMENT_WIDTH } : null,
|
|
153
|
+
placed.field,
|
|
97
154
|
// After the caller's style: a paragraph field keeps its 88px floor.
|
|
98
155
|
multiline ? MULTILINE : null,
|
|
99
156
|
],
|
|
@@ -107,8 +164,45 @@ export function makeTextField(rn) {
|
|
|
107
164
|
},
|
|
108
165
|
...rest,
|
|
109
166
|
});
|
|
110
|
-
|
|
111
|
-
|
|
167
|
+
const slotTestID = (own, suffix) => own || (testID ? `${testID}-${suffix}` : undefined);
|
|
168
|
+
const field = boxed
|
|
169
|
+
? h(
|
|
170
|
+
View,
|
|
171
|
+
{
|
|
172
|
+
style: [
|
|
173
|
+
{ justifyContent: "center" },
|
|
174
|
+
disabled && !framed ? { opacity: t.disabledOpacity } : null,
|
|
175
|
+
placed.box,
|
|
176
|
+
// Unlabelled, the box is the field's outermost one.
|
|
177
|
+
framed ? null : containerStyle,
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
input,
|
|
181
|
+
leading
|
|
182
|
+
? h(Adornment, {
|
|
183
|
+
side: "start",
|
|
184
|
+
icon: leadingIcon,
|
|
185
|
+
testID: slotTestID(leadingTestID, "leading-icon"),
|
|
186
|
+
t,
|
|
187
|
+
multiline,
|
|
188
|
+
})
|
|
189
|
+
: null,
|
|
190
|
+
trailing
|
|
191
|
+
? h(Adornment, {
|
|
192
|
+
side: "end",
|
|
193
|
+
icon: trailingIcon,
|
|
194
|
+
onPress: onTrailingPress,
|
|
195
|
+
accessibilityLabel: trailingAccessibilityLabel,
|
|
196
|
+
disabled,
|
|
197
|
+
testID: slotTestID(trailingTestID, "trailing-icon"),
|
|
198
|
+
t,
|
|
199
|
+
multiline,
|
|
200
|
+
})
|
|
201
|
+
: null,
|
|
202
|
+
)
|
|
203
|
+
: input;
|
|
204
|
+
if (!framed) return field;
|
|
205
|
+
return h(
|
|
112
206
|
FieldFrame,
|
|
113
207
|
{
|
|
114
208
|
t,
|
|
@@ -124,7 +218,7 @@ export function makeTextField(rn) {
|
|
|
124
218
|
},
|
|
125
219
|
style: containerStyle,
|
|
126
220
|
},
|
|
127
|
-
|
|
221
|
+
field,
|
|
128
222
|
);
|
|
129
223
|
});
|
|
130
224
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.249.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -3526,7 +3526,7 @@ const PRIMITIVES = [
|
|
|
3526
3526
|
{
|
|
3527
3527
|
name: "TextField",
|
|
3528
3528
|
description:
|
|
3529
|
-
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3529
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field, or around the field and its icons. An icon goes INSIDE the field, never beside it: `leadingIcon` / `trailingIcon` take a lucide name (`<TextField trailingIcon="map-pin" … />`), and `onTrailingPress` with a `trailingAccessibilityLabel` makes the trailing one a button (a clear control, "use my location"). Never wrap a field in a bordered row of your own to put an icon or a button beside it: the field draws its own border, so that shows a box inside a box. Use it by default instead of styling a bare `TextInput`; a field it cannot draw (a one-time code split into digit boxes, an amount with a stepper inside its border) is your own build that keeps a label, the input colours of the theme and an error message.',
|
|
3530
3530
|
rnComponent: null,
|
|
3531
3531
|
docsUrl: null,
|
|
3532
3532
|
},
|
|
@@ -3547,7 +3547,7 @@ const PRIMITIVES = [
|
|
|
3547
3547
|
{
|
|
3548
3548
|
name: "SearchField",
|
|
3549
3549
|
description:
|
|
3550
|
-
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it
|
|
3550
|
+
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — the default way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never add a magnifier of your own to it; its glyph and clear control fill the icon slots of `TextField`, so it takes no `leadingIcon` / `trailingIcon`. Use it rather than a `TextInput` + an icon of your own; when the request needs a search box it cannot draw (a scope picker inside the box, a voice button beside the clear control), build your own in the input look of the theme.',
|
|
3551
3551
|
rnComponent: null,
|
|
3552
3552
|
docsUrl: null,
|
|
3553
3553
|
},
|
|
@@ -6322,7 +6322,11 @@ const CONTRACT = deepFreeze({
|
|
|
6322
6322
|
// `<DataTable>`, `<FilterBar>`, `<Form>` / `<FieldRow>` / `<FormSection>`
|
|
6323
6323
|
// / `<FormActions>` / `<FormField>`, `<SortableList>`, `<Board>`;
|
|
6324
6324
|
// `required` on TextField, Select, RadioGroup, PeoplePicker, FileUpload.
|
|
6325
|
-
|
|
6325
|
+
// 1.203.0: additive (sc-12232) — `<TextField>` takes `leadingIcon`,
|
|
6326
|
+
// `trailingIcon`, `onTrailingPress`, `trailingAccessibilityLabel` and the
|
|
6327
|
+
// slots' testIDs; `<SearchField>` draws through them. New warnings
|
|
6328
|
+
// `field-in-bordered-row`, `field-trailing-press-unnamed`.
|
|
6329
|
+
version: "1.203.0",
|
|
6326
6330
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6327
6331
|
hooks: HOOKS,
|
|
6328
6332
|
primitives: PRIMITIVES,
|