@colixsystems/widget-sdk 0.247.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.
Files changed (42) hide show
  1. package/README.md +76 -6
  2. package/dist/board-auto-scroll.js +59 -0
  3. package/dist/board-view.js +327 -0
  4. package/dist/contract.cjs +76 -4
  5. package/dist/contract.js +76 -4
  6. package/dist/data-table-logic.js +179 -0
  7. package/dist/data-table-view.js +388 -0
  8. package/dist/data-table.js +22 -0
  9. package/dist/data-table.native.js +14 -0
  10. package/dist/drag-kit-view.js +310 -0
  11. package/dist/file-upload-view.js +13 -2
  12. package/dist/filter-bar-logic.js +143 -0
  13. package/dist/filter-bar-view.js +381 -0
  14. package/dist/filter-bar.js +15 -0
  15. package/dist/filter-bar.native.js +13 -0
  16. package/dist/form-control-logic.js +20 -3
  17. package/dist/form-control-view.js +18 -0
  18. package/dist/form-layout-logic.js +92 -0
  19. package/dist/form-view.js +268 -0
  20. package/dist/form.js +15 -0
  21. package/dist/form.native.js +9 -0
  22. package/dist/index.d.ts +402 -8
  23. package/dist/index.js +8 -0
  24. package/dist/index.native.js +8 -0
  25. package/dist/linter.cjs +156 -0
  26. package/dist/linter.js +156 -0
  27. package/dist/menu-view.js +5 -2
  28. package/dist/people-picker-view.js +3 -0
  29. package/dist/radio-group-view.js +7 -3
  30. package/dist/search-field-tokens.js +4 -6
  31. package/dist/search-field-view.js +43 -123
  32. package/dist/select-view.js +7 -2
  33. package/dist/sortable-list-view.js +228 -0
  34. package/dist/sortable-logic.js +273 -0
  35. package/dist/sortable-tokens.js +120 -0
  36. package/dist/sortable-view.js +24 -0
  37. package/dist/sortable.js +39 -0
  38. package/dist/sortable.native.js +30 -0
  39. package/dist/text-field-adornment.js +93 -0
  40. package/dist/text-field-view.js +109 -10
  41. package/package.json +1 -1
  42. package/src/contract.js +76 -4
package/dist/contract.cjs CHANGED
@@ -3361,6 +3361,13 @@ const PRIMITIVES = [
3361
3361
  rnComponent: null,
3362
3362
  docsUrl: null,
3363
3363
  },
3364
+ {
3365
+ name: "DataTable",
3366
+ description:
3367
+ 'A list of records laid out for its own width. `<DataTable columns={[{ key: "name", label: "Name", flex: 2, sortable: true }, { key: "status", label: "Status", render: (row) => <Badge label={row.status} /> }, { key: "total", label: "Total", align: "right" }]} rows={data} loading={loading} onRowPress={(row) => open(row.id)} />` — use it by default for a list or table of records (datastore rows): it is stacked label/value cards when narrow, one card per row at medium (the first column as the title, the rest on one line), and aligned columns under a sticky header when wide, from its own measured width, so it needs no tier code of yours. When the table needs what it cannot do (editable cells, grouped or collapsible rows, resizable columns, a pivot), build your own from `<ListHeader>` over a `<List>` of `<ListRow>`s with the same `flex` per column. Columns: `key`, `label`, `flex` (default 1, the same in the header and the rows), `align` ("left" / "center" / "right"), `minWidth`, `sortable`, `render(row, column)` (a string or number becomes the cell\'s text). Props: `columns`, `rows`, `keyField` (default "id"), `renderCell(row, column)` (every cell without its own `render`; return `undefined` for the default), `onRowPress(row)`, `sort` / `defaultSort` (`{ key, direction: "asc" | "desc" }`), `onSortChange(sort)`, `maxHeight` (the rows\' scroll box, default 480), `loading` (placeholder rows while `rows` is empty), `emptyTitle`, `emptyMessage` (an empty state, "No results" by default), `sortLabels` (`{ sort, ascending, descending }` in the widget\'s language), the Lists overrides (`rowPadding`, `gap`, …), `style`, `testID`, `accessibilityLabel`. A sortable header is a 44px button named by its column and described by its sort, and a change of sort is announced once; the card layouts offer the same sort from a menu. With `onSortChange` you order the rows (re-query with the new sort) and the table draws `rows` as given; without it the table sorts them itself (a `Date` value sorts as a date). A controlled `sort` without `onSortChange` is display-only: the rows follow it and the headers are plain labels. The sticky header is filled with the rows\' Lists background, else the theme surface. It is styled by Design > Components > Lists (and Cards for the card layouts).',
3368
+ rnComponent: null,
3369
+ docsUrl: null,
3370
+ },
3364
3371
  {
3365
3372
  name: "Badge",
3366
3373
  description:
@@ -3418,6 +3425,41 @@ const PRIMITIVES = [
3418
3425
  rnComponent: null,
3419
3426
  docsUrl: null,
3420
3427
  },
3428
+ {
3429
+ name: "Form",
3430
+ description:
3431
+ 'A form laid out in width tiers from its own measured width. `<Form><FieldRow><TextField label="First name" … /><TextField label="Last name" … /></FieldRow><TextField label="Email" required … /><FormActions><Button variant="secondary" title="Cancel" … /><Button title="Save" … /></FormActions></Form>` — use it by default for a widget that captures or edits data: its fields sit one to a line when narrow, two at medium and up to three when wide, and the form is capped near 1000px and centred, so it is never flush left, a narrow strip in an empty page, or a wall of giant inputs. A `<FormField>` or a control that frames its own label (`<TextField>`, `<Select>`, `<RadioGroup>`, `<PeoplePicker>`, `<FileUpload>`) is one field; a multiline field, a `<FormField span="full">`, a `<FieldRow>`, a `<FormSection>`, a `<FormActions>` and anything else (a `<Banner>`, a paragraph) take the whole line. Its direct children are read, so do not wrap them in a fragment. Props: `children`, `columns` ("auto" — three when wide — or 1–3, the most fields to a line), `maxWidth` (1000 by default, `null` for none, for a form in a side panel), `gap` (a theme spacing name, default "md", or a number), `style` (the outer box), `testID`, `accessibilityLabel`. When the form needs what it cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay it out yourself in the app\'s look with `useContainerWidth()` and `widthTier()`, keeping the three tiers.',
3432
+ rnComponent: null,
3433
+ docsUrl: null,
3434
+ },
3435
+ {
3436
+ name: "FieldRow",
3437
+ description:
3438
+ 'Short related fields on one line inside a `<Form>`: first + last name, city + postal code, expiry + CVC. `<FieldRow><TextField label="City" … /><TextField label="Postal code" … /></FieldRow>`. Up to three fields share the line; when narrow they stack. Props: `children`, `gap`, `style`, `testID`.',
3439
+ rnComponent: null,
3440
+ docsUrl: null,
3441
+ },
3442
+ {
3443
+ name: "FormSection",
3444
+ description:
3445
+ 'A titled group of fields inside a `<Form>`, laid out in the form\'s own columns. `<FormSection title="Payment" description="Charged when the order ships">…</FormSection>`. Group a form past about six fields into sections. The title is a heading for screen readers too, in the subtitle text role; the description is in the helper colour. Props: `title`, `description`, `children`, `gap`, `style`, `testID`.',
3446
+ rnComponent: null,
3447
+ docsUrl: null,
3448
+ },
3449
+ {
3450
+ name: "FormActions",
3451
+ description:
3452
+ 'A form\'s buttons in normal flow after its fields, never over them. `<FormActions><Button variant="secondary" title="Cancel" onPress={cancel} /><Button title="Save" onPress={save} busy={saving} /></FormActions>`. Pass the secondary first and the primary last: at medium and wide they sit in a row at the trailing edge, the primary last; when narrow they stack full-width in the same order, the primary nearest the thumb. Props: `children`, `style`, `testID`. In a `<Modal>`, pass the buttons to its `footer` instead.',
3453
+ rnComponent: null,
3454
+ docsUrl: null,
3455
+ },
3456
+ {
3457
+ name: "FormField",
3458
+ description:
3459
+ 'The label, required mark, helper and error of the form controls, round any control. `<FormField label="Colour" required helper="Shown on the card" error={colourError}><SwatchPicker … /></FormField>`. A control that frames its own label (`<TextField>`, `<Select>`, `<RadioGroup>`, `<PeoplePicker>`, `<FileUpload>`) is handed them instead, so one label is drawn (a prop the control sets itself wins, `disabled={false}` included; the field\'s `testID` goes to the control, or to a wrapper round it when the control has its own); those five also take `required` directly. Any other child is framed here and named to a screen reader: on the web as a group, with the helper or error as its description; on a device the one child itself takes the label (with "Required") as its name and the helper or error as its hint, unless it names itself, when the label speaks the required word. Give that child no label of its own. `required` draws the mark and adds the app\'s "Required" to the spoken name; it does not validate, so pass `error` yourself. Props: `label`, `required`, `helper`, `error`, `disabled`, `span` ("full" takes the form\'s whole line), `style`, `testID`, `children`.',
3460
+ rnComponent: null,
3461
+ docsUrl: null,
3462
+ },
3421
3463
  {
3422
3464
  name: "Banner",
3423
3465
  description:
@@ -3484,7 +3526,7 @@ const PRIMITIVES = [
3484
3526
  {
3485
3527
  name: "TextField",
3486
3528
  description:
3487
- '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.',
3488
3530
  rnComponent: null,
3489
3531
  docsUrl: null,
3490
3532
  },
@@ -3505,7 +3547,7 @@ const PRIMITIVES = [
3505
3547
  {
3506
3548
  name: "SearchField",
3507
3549
  description:
3508
- '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. 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.',
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.',
3509
3551
  rnComponent: null,
3510
3552
  docsUrl: null,
3511
3553
  },
@@ -3516,6 +3558,13 @@ const PRIMITIVES = [
3516
3558
  rnComponent: null,
3517
3559
  docsUrl: null,
3518
3560
  },
3561
+ {
3562
+ name: "FilterBar",
3563
+ description:
3564
+ 'Search plus facet filters that lay themselves out for their own width. `<FilterBar query={query} onQueryChange={setQuery} facets={[{ key: "status", label: t("status", "Status"), options: [{ value: "open", label: t("open", "Open") }] }]} value={filters} onChange={setFilters} sort={{ options: sortOptions, value: sortBy, onChange: setSortBy }} />` — use it by default for a list\'s search and filters; when the filters need what it cannot do (a date-range facet, a price slider, a map-area filter, saved filter presets), build your own toolbar from `<SearchField>`, `<Select>`, `<Chip>` and `<Modal>` in the same shape. It measures its own unpadded box: medium and wide are ONE toolbar row (the search field taking the free width, one compact `<Select>` per facet whose placeholder reads "Status: Any", then the sort) with each applied facet beneath as a removable chip ("Status: Open") and a "Clear all" that resets the facets and the query; narrow, or a row whose dropdowns would not fit, is the search field plus ONE "Filters · 2" button opening the facets as full-width Selects in a `<Modal>` sheet, applied on Apply (Clear empties the sheet\'s facets and puts its sort back on the first option). The applied chips and "Clear all" show beneath at every width, so a narrow user can drop one filter without the sheet. A facet is `{ key, label, options, multiple?, searchable? }`; `value` is `{ [key]: value }`, a list for a `multiple` facet, and `onChange(next)` fires once per edit with a cleared facet\'s key removed. `onQueryChange(query)` gets the trimmed query once typing pauses (`queryDebounceMs`, default 250) and at once on clear or submit; the search field shows only with it. Both are controlled, so seed `query` and `value` from route params or `useWidgetRoute`. Its parts keep their own looks (Design > Components > Search fields, Inputs, Dropdown menus, Chips, Dialogs); it adds no theme scope. Translate the copy with `t()`: `searchPlaceholder`, `anyText` ("Any"), `filtersText` ("Filters"), `activeCountText` ("{count} active"), `clearAllText`, `applyText`, `clearText`, `activeFiltersText`, `removeText` ("Remove {label}"). Other props: `style` (the outer box), `testID`.',
3565
+ rnComponent: null,
3566
+ docsUrl: null,
3567
+ },
3519
3568
  {
3520
3569
  name: "RadioGroup",
3521
3570
  description:
@@ -3548,7 +3597,7 @@ const PRIMITIVES = [
3548
3597
  {
3549
3598
  name: "FileUpload",
3550
3599
  description:
3551
- 'Themed file chooser. `<FileUpload label="Attachments" accept="image/*,.pdf" multiple files={files} onFilesChange={setFiles} />` — the default way to let the user pick files to upload, whether they upload at once or with a form. It is built on `<FilePicker>`: a button (Design > Components > Buttons) inside the app\'s input field (Design > Components > Inputs), the chosen files listed under it with their name, size and a remove control, and the same `label` / `helper` / `error` / `disabled` as every form control. The button opens the system file picker on both hosts and is the only way in on a device; on the web a file can also be dropped onto the field. A picked or dropped file that `accept` rules out is not added and the field shows an error. It uploads nothing itself: call `upload(file)` from `useFilestoreUpload` in `onPick` (or when the form is submitted) and pass `uploading` while it runs; the field then shows a `<Spinner>` with "Uploading…" and locks (no percentage is reported). Props: `accept` (an HTML accept value: "image/*", ".pdf", "application/pdf"), `multiple` (default false: a new pick replaces the file), `files` + `onFilesChange(files)` to own the list (omit both and it keeps its own; pass `files={[]}` to list nothing), `onPick(file)` (an array when `multiple`) once per pick or drop with only the new files, `onRemove(file, index)`, `onReject(files, reason)` (reason "type" for a file `accept` rules out, "count" for the files beyond the first when not `multiple`), `uploading`, `label`, `helper`, `error` (a message, or `true`), `disabled`, `buttonLabel` (default "Choose file" / "Choose files"), `buttonVariant` ("primary" | "secondary" | "outlined", the default), `uploadingLabel`, `emptyLabel`, `dropLabel`, `rejectedMessage`, `singleFileMessage`, `unsupportedMessage`, `removeAccessibilityLabel`, `accessibilityLabel`, `style` (the field), `containerStyle`, `testID` (also tags `<testID>-button`, `<testID>-file-<i>` and `<testID>-remove-<i>`). Pass translated copy for every label. Use it rather than a `<FilePicker>` round your own button, file list or "Uploading…" text; when the request needs what it cannot do (a grid of photo thumbnails, a cover image replaced on tap), wrap `<FilePicker>` round your own trigger in the app\'s look and still upload with `useFilestoreUpload`.',
3600
+ 'Themed file chooser. `<FileUpload label="Attachments" accept="image/*,.pdf" multiple files={files} onFilesChange={setFiles} />` — the default way to let the user pick files to upload, whether they upload at once or with a form. It is built on `<FilePicker>`: a button (Design > Components > Buttons) inside the app\'s input field (Design > Components > Inputs), the chosen files listed under it with their name, size and a remove control, and the same `label` / `required` / `helper` / `error` / `disabled` as every form control. The button opens the system file picker on both hosts and is the only way in on a device; on the web a file can also be dropped onto the field. A picked or dropped file that `accept` rules out is not added and the field shows an error. It uploads nothing itself: call `upload(file)` from `useFilestoreUpload` in `onPick` (or when the form is submitted) and pass `uploading` while it runs; the field then shows a `<Spinner>` with "Uploading…" and locks (no percentage is reported). Props: `accept` (an HTML accept value: "image/*", ".pdf", "application/pdf"), `multiple` (default false: a new pick replaces the file), `files` + `onFilesChange(files)` to own the list (omit both and it keeps its own; pass `files={[]}` to list nothing), `onPick(file)` (an array when `multiple`) once per pick or drop with only the new files, `onRemove(file, index)`, `onReject(files, reason)` (reason "type" for a file `accept` rules out, "count" for the files beyond the first when not `multiple`), `uploading`, `label`, `required` (a mark after the label and "Required" in the picker\'s spoken name; it does not validate), `helper`, `error` (a message, or `true`), `disabled`, `buttonLabel` (default "Choose file" / "Choose files"), `buttonVariant` ("primary" | "secondary" | "outlined", the default), `uploadingLabel`, `emptyLabel`, `dropLabel`, `rejectedMessage`, `singleFileMessage`, `unsupportedMessage`, `removeAccessibilityLabel`, `accessibilityLabel`, `style` (the field), `containerStyle`, `testID` (also tags `<testID>-button`, `<testID>-file-<i>` and `<testID>-remove-<i>`). Pass translated copy for every label. Use it rather than a `<FilePicker>` round your own button, file list or "Uploading…" text; when the request needs what it cannot do (a grid of photo thumbnails, a cover image replaced on tap), wrap `<FilePicker>` round your own trigger in the app\'s look and still upload with `useFilestoreUpload`.',
3552
3601
  rnComponent: null,
3553
3602
  docsUrl: null,
3554
3603
  },
@@ -3574,6 +3623,21 @@ const PRIMITIVES = [
3574
3623
  rnComponent: null,
3575
3624
  docsUrl: null,
3576
3625
  },
3626
+ // Drag-and-drop: rows reordered and cards moved between columns.
3627
+ {
3628
+ name: "SortableList",
3629
+ description:
3630
+ 'A list the user reorders by dragging. `<SortableList items={steps} onReorder={(next) => setSteps(next)} renderItem={({ item }) => <Text>{item.title}</Text>} />` — use it by default for a list the user reorders (a ranking, a playlist, the steps of a routine, priorities); when the request needs what it cannot do (free-form positioning on a canvas, a drag between two widgets, a drop onto a calendar slot, dragging several rows at once), build your own with `Animated` + `PanResponder` from react-native. Each row is a `<ListRow>` (the theme\'s `list` scope; `variant="card"` draws card rows) led by a grip handle that owns the drag on both hosts: the row lifts and follows the finger, the other rows step aside, the gap where it will land shows in the soft primary, and a release settles it there, or springs it back and calls nothing when nothing changed. A tap or Enter on the handle opens its move options (Move up, Move down, Move to top, Move to bottom), so dragging is never the only way; on the web the arrow keys move a focused handle, a screen reader is offered the same moves as actions, and every move is announced. `onReorder(nextItems, { from, to, item })` runs once per real change: show `nextItems` at once and save the new order yourself. Props: `items`, `onReorder`, `renderItem({ item, index, dragging })` (a row\'s content; with none the item\'s title shows), `keyExtractor` (default `item.id`), `getItemLabel` (the name a move announces; default its title, name or label), `canDrag` (a boolean or `(item) => boolean`; a row it refuses has no handle; it is read on every render, so pass `useCanWrite`\'s flag as it is), `variant` "plain" (default) | "card", `labels` (translated words: `handle` "Move {item}", `moveUp`, `moveDown`, `moveToTop`, `moveToBottom`, `moved` "{item} moved to position {position} of {total}", `hint`), `style`, `testID`, `accessibilityLabel`. In a list taller than the screen a drag reaches the rows in view; the move options reach any place.',
3631
+ rnComponent: null,
3632
+ docsUrl: null,
3633
+ },
3634
+ {
3635
+ name: "Board",
3636
+ description:
3637
+ 'Cards the user moves between columns: a kanban, a pipeline, a status board. `<Board columns={[{ key: "todo", title: t("todo", "To do") }, { key: "done", title: t("done", "Done") }]} items={rows} getColumn={(row) => row[props.statusField]} onMove={(row, { toColumn }) => move(row, toColumn)} renderCard={({ item }) => <Text>{item[props.titleField]}</Text>} canDrag={canWrite} />` — use it by default for cards the user moves between columns; when the request needs what it cannot do (free-form positioning on a canvas, a drag between two widgets, a drop onto a calendar slot, swimlanes in two directions), build your own with `Animated` + `PanResponder` from react-native. Each card is a `<Card>` (the theme\'s `card` scope) with a grip handle. The board hit-tests the drop itself, highlights the one column the card is over in the soft primary with a line where it will land, and on a narrow box lays fixed-width columns in a sideways scroll that scrolls by itself while a card is held at its edge. A tap or Enter on the handle opens its move options (Move up, Move down, Move to <column>), so dragging is never the only way; on the web the arrow keys move a focused card (left and right to the end of the next column), a screen reader is offered the same moves as actions, and every move is announced. `onMove(item, { fromColumn, toColumn, index })` runs once per real change (`index` is its place in the target column); a drag released over no column, or where it started, springs back and calls nothing. The board draws `items` as you pass them, so show a moved card in its new column at once: keep an optimistic map until your write lands and you refetch, and on failure drop it with a toast so the card returns. Props: `columns` (`[{ key, title }]`), `items`, `getColumn(item)` (its column key), `onMove`, `renderCard({ item, index, column, dragging })` (a card\'s content), `keyExtractor`, `getItemLabel`, `canDrag` (a boolean or per item, read on every render), `reorderInColumn` (default true; false when cards keep no order of their own, and a drop in its own column then changes nothing), `columnWidth` (the narrow width of a column, default 280), `labels` (as SortableList, plus `moveToColumn` "Move to {column}" and `movedToColumn` "{item} moved to {column}, position {position}"), `style`, `testID`, `accessibilityLabel`.',
3638
+ rnComponent: null,
3639
+ docsUrl: null,
3640
+ },
3577
3641
  ];
3578
3642
 
3579
3643
  const CATEGORIES = [
@@ -6258,7 +6322,15 @@ const CONTRACT = deepFreeze({
6258
6322
  // 1.201.0: additive (sc-12070, sc-12071, sc-12072) — new `<Grid>`,
6259
6323
  // `<Chart>` + `chart` theme scope + `chartColors`, `<PeoplePicker>`,
6260
6324
  // searchable `<Select>`; `useGroups` takes `ids`.
6261
- version: "1.201.0",
6325
+ // 1.202.0: additive (sc-12073, sc-12074, sc-12075, sc-12076) — new
6326
+ // `<DataTable>`, `<FilterBar>`, `<Form>` / `<FieldRow>` / `<FormSection>`
6327
+ // / `<FormActions>` / `<FormField>`, `<SortableList>`, `<Board>`;
6328
+ // `required` on TextField, Select, RadioGroup, PeoplePicker, FileUpload.
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",
6262
6334
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6263
6335
  hooks: HOOKS,
6264
6336
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -3361,6 +3361,13 @@ const PRIMITIVES = [
3361
3361
  rnComponent: null,
3362
3362
  docsUrl: null,
3363
3363
  },
3364
+ {
3365
+ name: "DataTable",
3366
+ description:
3367
+ 'A list of records laid out for its own width. `<DataTable columns={[{ key: "name", label: "Name", flex: 2, sortable: true }, { key: "status", label: "Status", render: (row) => <Badge label={row.status} /> }, { key: "total", label: "Total", align: "right" }]} rows={data} loading={loading} onRowPress={(row) => open(row.id)} />` — use it by default for a list or table of records (datastore rows): it is stacked label/value cards when narrow, one card per row at medium (the first column as the title, the rest on one line), and aligned columns under a sticky header when wide, from its own measured width, so it needs no tier code of yours. When the table needs what it cannot do (editable cells, grouped or collapsible rows, resizable columns, a pivot), build your own from `<ListHeader>` over a `<List>` of `<ListRow>`s with the same `flex` per column. Columns: `key`, `label`, `flex` (default 1, the same in the header and the rows), `align` ("left" / "center" / "right"), `minWidth`, `sortable`, `render(row, column)` (a string or number becomes the cell\'s text). Props: `columns`, `rows`, `keyField` (default "id"), `renderCell(row, column)` (every cell without its own `render`; return `undefined` for the default), `onRowPress(row)`, `sort` / `defaultSort` (`{ key, direction: "asc" | "desc" }`), `onSortChange(sort)`, `maxHeight` (the rows\' scroll box, default 480), `loading` (placeholder rows while `rows` is empty), `emptyTitle`, `emptyMessage` (an empty state, "No results" by default), `sortLabels` (`{ sort, ascending, descending }` in the widget\'s language), the Lists overrides (`rowPadding`, `gap`, …), `style`, `testID`, `accessibilityLabel`. A sortable header is a 44px button named by its column and described by its sort, and a change of sort is announced once; the card layouts offer the same sort from a menu. With `onSortChange` you order the rows (re-query with the new sort) and the table draws `rows` as given; without it the table sorts them itself (a `Date` value sorts as a date). A controlled `sort` without `onSortChange` is display-only: the rows follow it and the headers are plain labels. The sticky header is filled with the rows\' Lists background, else the theme surface. It is styled by Design > Components > Lists (and Cards for the card layouts).',
3368
+ rnComponent: null,
3369
+ docsUrl: null,
3370
+ },
3364
3371
  {
3365
3372
  name: "Badge",
3366
3373
  description:
@@ -3418,6 +3425,41 @@ const PRIMITIVES = [
3418
3425
  rnComponent: null,
3419
3426
  docsUrl: null,
3420
3427
  },
3428
+ {
3429
+ name: "Form",
3430
+ description:
3431
+ 'A form laid out in width tiers from its own measured width. `<Form><FieldRow><TextField label="First name" … /><TextField label="Last name" … /></FieldRow><TextField label="Email" required … /><FormActions><Button variant="secondary" title="Cancel" … /><Button title="Save" … /></FormActions></Form>` — use it by default for a widget that captures or edits data: its fields sit one to a line when narrow, two at medium and up to three when wide, and the form is capped near 1000px and centred, so it is never flush left, a narrow strip in an empty page, or a wall of giant inputs. A `<FormField>` or a control that frames its own label (`<TextField>`, `<Select>`, `<RadioGroup>`, `<PeoplePicker>`, `<FileUpload>`) is one field; a multiline field, a `<FormField span="full">`, a `<FieldRow>`, a `<FormSection>`, a `<FormActions>` and anything else (a `<Banner>`, a paragraph) take the whole line. Its direct children are read, so do not wrap them in a fragment. Props: `children`, `columns` ("auto" — three when wide — or 1–3, the most fields to a line), `maxWidth` (1000 by default, `null` for none, for a form in a side panel), `gap` (a theme spacing name, default "md", or a number), `style` (the outer box), `testID`, `accessibilityLabel`. When the form needs what it cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay it out yourself in the app\'s look with `useContainerWidth()` and `widthTier()`, keeping the three tiers.',
3432
+ rnComponent: null,
3433
+ docsUrl: null,
3434
+ },
3435
+ {
3436
+ name: "FieldRow",
3437
+ description:
3438
+ 'Short related fields on one line inside a `<Form>`: first + last name, city + postal code, expiry + CVC. `<FieldRow><TextField label="City" … /><TextField label="Postal code" … /></FieldRow>`. Up to three fields share the line; when narrow they stack. Props: `children`, `gap`, `style`, `testID`.',
3439
+ rnComponent: null,
3440
+ docsUrl: null,
3441
+ },
3442
+ {
3443
+ name: "FormSection",
3444
+ description:
3445
+ 'A titled group of fields inside a `<Form>`, laid out in the form\'s own columns. `<FormSection title="Payment" description="Charged when the order ships">…</FormSection>`. Group a form past about six fields into sections. The title is a heading for screen readers too, in the subtitle text role; the description is in the helper colour. Props: `title`, `description`, `children`, `gap`, `style`, `testID`.',
3446
+ rnComponent: null,
3447
+ docsUrl: null,
3448
+ },
3449
+ {
3450
+ name: "FormActions",
3451
+ description:
3452
+ 'A form\'s buttons in normal flow after its fields, never over them. `<FormActions><Button variant="secondary" title="Cancel" onPress={cancel} /><Button title="Save" onPress={save} busy={saving} /></FormActions>`. Pass the secondary first and the primary last: at medium and wide they sit in a row at the trailing edge, the primary last; when narrow they stack full-width in the same order, the primary nearest the thumb. Props: `children`, `style`, `testID`. In a `<Modal>`, pass the buttons to its `footer` instead.',
3453
+ rnComponent: null,
3454
+ docsUrl: null,
3455
+ },
3456
+ {
3457
+ name: "FormField",
3458
+ description:
3459
+ 'The label, required mark, helper and error of the form controls, round any control. `<FormField label="Colour" required helper="Shown on the card" error={colourError}><SwatchPicker … /></FormField>`. A control that frames its own label (`<TextField>`, `<Select>`, `<RadioGroup>`, `<PeoplePicker>`, `<FileUpload>`) is handed them instead, so one label is drawn (a prop the control sets itself wins, `disabled={false}` included; the field\'s `testID` goes to the control, or to a wrapper round it when the control has its own); those five also take `required` directly. Any other child is framed here and named to a screen reader: on the web as a group, with the helper or error as its description; on a device the one child itself takes the label (with "Required") as its name and the helper or error as its hint, unless it names itself, when the label speaks the required word. Give that child no label of its own. `required` draws the mark and adds the app\'s "Required" to the spoken name; it does not validate, so pass `error` yourself. Props: `label`, `required`, `helper`, `error`, `disabled`, `span` ("full" takes the form\'s whole line), `style`, `testID`, `children`.',
3460
+ rnComponent: null,
3461
+ docsUrl: null,
3462
+ },
3421
3463
  {
3422
3464
  name: "Banner",
3423
3465
  description:
@@ -3484,7 +3526,7 @@ const PRIMITIVES = [
3484
3526
  {
3485
3527
  name: "TextField",
3486
3528
  description:
3487
- '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.',
3488
3530
  rnComponent: null,
3489
3531
  docsUrl: null,
3490
3532
  },
@@ -3505,7 +3547,7 @@ const PRIMITIVES = [
3505
3547
  {
3506
3548
  name: "SearchField",
3507
3549
  description:
3508
- '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. 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.',
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.',
3509
3551
  rnComponent: null,
3510
3552
  docsUrl: null,
3511
3553
  },
@@ -3516,6 +3558,13 @@ const PRIMITIVES = [
3516
3558
  rnComponent: null,
3517
3559
  docsUrl: null,
3518
3560
  },
3561
+ {
3562
+ name: "FilterBar",
3563
+ description:
3564
+ 'Search plus facet filters that lay themselves out for their own width. `<FilterBar query={query} onQueryChange={setQuery} facets={[{ key: "status", label: t("status", "Status"), options: [{ value: "open", label: t("open", "Open") }] }]} value={filters} onChange={setFilters} sort={{ options: sortOptions, value: sortBy, onChange: setSortBy }} />` — use it by default for a list\'s search and filters; when the filters need what it cannot do (a date-range facet, a price slider, a map-area filter, saved filter presets), build your own toolbar from `<SearchField>`, `<Select>`, `<Chip>` and `<Modal>` in the same shape. It measures its own unpadded box: medium and wide are ONE toolbar row (the search field taking the free width, one compact `<Select>` per facet whose placeholder reads "Status: Any", then the sort) with each applied facet beneath as a removable chip ("Status: Open") and a "Clear all" that resets the facets and the query; narrow, or a row whose dropdowns would not fit, is the search field plus ONE "Filters · 2" button opening the facets as full-width Selects in a `<Modal>` sheet, applied on Apply (Clear empties the sheet\'s facets and puts its sort back on the first option). The applied chips and "Clear all" show beneath at every width, so a narrow user can drop one filter without the sheet. A facet is `{ key, label, options, multiple?, searchable? }`; `value` is `{ [key]: value }`, a list for a `multiple` facet, and `onChange(next)` fires once per edit with a cleared facet\'s key removed. `onQueryChange(query)` gets the trimmed query once typing pauses (`queryDebounceMs`, default 250) and at once on clear or submit; the search field shows only with it. Both are controlled, so seed `query` and `value` from route params or `useWidgetRoute`. Its parts keep their own looks (Design > Components > Search fields, Inputs, Dropdown menus, Chips, Dialogs); it adds no theme scope. Translate the copy with `t()`: `searchPlaceholder`, `anyText` ("Any"), `filtersText` ("Filters"), `activeCountText` ("{count} active"), `clearAllText`, `applyText`, `clearText`, `activeFiltersText`, `removeText` ("Remove {label}"). Other props: `style` (the outer box), `testID`.',
3565
+ rnComponent: null,
3566
+ docsUrl: null,
3567
+ },
3519
3568
  {
3520
3569
  name: "RadioGroup",
3521
3570
  description:
@@ -3548,7 +3597,7 @@ const PRIMITIVES = [
3548
3597
  {
3549
3598
  name: "FileUpload",
3550
3599
  description:
3551
- 'Themed file chooser. `<FileUpload label="Attachments" accept="image/*,.pdf" multiple files={files} onFilesChange={setFiles} />` — the default way to let the user pick files to upload, whether they upload at once or with a form. It is built on `<FilePicker>`: a button (Design > Components > Buttons) inside the app\'s input field (Design > Components > Inputs), the chosen files listed under it with their name, size and a remove control, and the same `label` / `helper` / `error` / `disabled` as every form control. The button opens the system file picker on both hosts and is the only way in on a device; on the web a file can also be dropped onto the field. A picked or dropped file that `accept` rules out is not added and the field shows an error. It uploads nothing itself: call `upload(file)` from `useFilestoreUpload` in `onPick` (or when the form is submitted) and pass `uploading` while it runs; the field then shows a `<Spinner>` with "Uploading…" and locks (no percentage is reported). Props: `accept` (an HTML accept value: "image/*", ".pdf", "application/pdf"), `multiple` (default false: a new pick replaces the file), `files` + `onFilesChange(files)` to own the list (omit both and it keeps its own; pass `files={[]}` to list nothing), `onPick(file)` (an array when `multiple`) once per pick or drop with only the new files, `onRemove(file, index)`, `onReject(files, reason)` (reason "type" for a file `accept` rules out, "count" for the files beyond the first when not `multiple`), `uploading`, `label`, `helper`, `error` (a message, or `true`), `disabled`, `buttonLabel` (default "Choose file" / "Choose files"), `buttonVariant` ("primary" | "secondary" | "outlined", the default), `uploadingLabel`, `emptyLabel`, `dropLabel`, `rejectedMessage`, `singleFileMessage`, `unsupportedMessage`, `removeAccessibilityLabel`, `accessibilityLabel`, `style` (the field), `containerStyle`, `testID` (also tags `<testID>-button`, `<testID>-file-<i>` and `<testID>-remove-<i>`). Pass translated copy for every label. Use it rather than a `<FilePicker>` round your own button, file list or "Uploading…" text; when the request needs what it cannot do (a grid of photo thumbnails, a cover image replaced on tap), wrap `<FilePicker>` round your own trigger in the app\'s look and still upload with `useFilestoreUpload`.',
3600
+ 'Themed file chooser. `<FileUpload label="Attachments" accept="image/*,.pdf" multiple files={files} onFilesChange={setFiles} />` — the default way to let the user pick files to upload, whether they upload at once or with a form. It is built on `<FilePicker>`: a button (Design > Components > Buttons) inside the app\'s input field (Design > Components > Inputs), the chosen files listed under it with their name, size and a remove control, and the same `label` / `required` / `helper` / `error` / `disabled` as every form control. The button opens the system file picker on both hosts and is the only way in on a device; on the web a file can also be dropped onto the field. A picked or dropped file that `accept` rules out is not added and the field shows an error. It uploads nothing itself: call `upload(file)` from `useFilestoreUpload` in `onPick` (or when the form is submitted) and pass `uploading` while it runs; the field then shows a `<Spinner>` with "Uploading…" and locks (no percentage is reported). Props: `accept` (an HTML accept value: "image/*", ".pdf", "application/pdf"), `multiple` (default false: a new pick replaces the file), `files` + `onFilesChange(files)` to own the list (omit both and it keeps its own; pass `files={[]}` to list nothing), `onPick(file)` (an array when `multiple`) once per pick or drop with only the new files, `onRemove(file, index)`, `onReject(files, reason)` (reason "type" for a file `accept` rules out, "count" for the files beyond the first when not `multiple`), `uploading`, `label`, `required` (a mark after the label and "Required" in the picker\'s spoken name; it does not validate), `helper`, `error` (a message, or `true`), `disabled`, `buttonLabel` (default "Choose file" / "Choose files"), `buttonVariant` ("primary" | "secondary" | "outlined", the default), `uploadingLabel`, `emptyLabel`, `dropLabel`, `rejectedMessage`, `singleFileMessage`, `unsupportedMessage`, `removeAccessibilityLabel`, `accessibilityLabel`, `style` (the field), `containerStyle`, `testID` (also tags `<testID>-button`, `<testID>-file-<i>` and `<testID>-remove-<i>`). Pass translated copy for every label. Use it rather than a `<FilePicker>` round your own button, file list or "Uploading…" text; when the request needs what it cannot do (a grid of photo thumbnails, a cover image replaced on tap), wrap `<FilePicker>` round your own trigger in the app\'s look and still upload with `useFilestoreUpload`.',
3552
3601
  rnComponent: null,
3553
3602
  docsUrl: null,
3554
3603
  },
@@ -3574,6 +3623,21 @@ const PRIMITIVES = [
3574
3623
  rnComponent: null,
3575
3624
  docsUrl: null,
3576
3625
  },
3626
+ // Drag-and-drop: rows reordered and cards moved between columns.
3627
+ {
3628
+ name: "SortableList",
3629
+ description:
3630
+ 'A list the user reorders by dragging. `<SortableList items={steps} onReorder={(next) => setSteps(next)} renderItem={({ item }) => <Text>{item.title}</Text>} />` — use it by default for a list the user reorders (a ranking, a playlist, the steps of a routine, priorities); when the request needs what it cannot do (free-form positioning on a canvas, a drag between two widgets, a drop onto a calendar slot, dragging several rows at once), build your own with `Animated` + `PanResponder` from react-native. Each row is a `<ListRow>` (the theme\'s `list` scope; `variant="card"` draws card rows) led by a grip handle that owns the drag on both hosts: the row lifts and follows the finger, the other rows step aside, the gap where it will land shows in the soft primary, and a release settles it there, or springs it back and calls nothing when nothing changed. A tap or Enter on the handle opens its move options (Move up, Move down, Move to top, Move to bottom), so dragging is never the only way; on the web the arrow keys move a focused handle, a screen reader is offered the same moves as actions, and every move is announced. `onReorder(nextItems, { from, to, item })` runs once per real change: show `nextItems` at once and save the new order yourself. Props: `items`, `onReorder`, `renderItem({ item, index, dragging })` (a row\'s content; with none the item\'s title shows), `keyExtractor` (default `item.id`), `getItemLabel` (the name a move announces; default its title, name or label), `canDrag` (a boolean or `(item) => boolean`; a row it refuses has no handle; it is read on every render, so pass `useCanWrite`\'s flag as it is), `variant` "plain" (default) | "card", `labels` (translated words: `handle` "Move {item}", `moveUp`, `moveDown`, `moveToTop`, `moveToBottom`, `moved` "{item} moved to position {position} of {total}", `hint`), `style`, `testID`, `accessibilityLabel`. In a list taller than the screen a drag reaches the rows in view; the move options reach any place.',
3631
+ rnComponent: null,
3632
+ docsUrl: null,
3633
+ },
3634
+ {
3635
+ name: "Board",
3636
+ description:
3637
+ 'Cards the user moves between columns: a kanban, a pipeline, a status board. `<Board columns={[{ key: "todo", title: t("todo", "To do") }, { key: "done", title: t("done", "Done") }]} items={rows} getColumn={(row) => row[props.statusField]} onMove={(row, { toColumn }) => move(row, toColumn)} renderCard={({ item }) => <Text>{item[props.titleField]}</Text>} canDrag={canWrite} />` — use it by default for cards the user moves between columns; when the request needs what it cannot do (free-form positioning on a canvas, a drag between two widgets, a drop onto a calendar slot, swimlanes in two directions), build your own with `Animated` + `PanResponder` from react-native. Each card is a `<Card>` (the theme\'s `card` scope) with a grip handle. The board hit-tests the drop itself, highlights the one column the card is over in the soft primary with a line where it will land, and on a narrow box lays fixed-width columns in a sideways scroll that scrolls by itself while a card is held at its edge. A tap or Enter on the handle opens its move options (Move up, Move down, Move to <column>), so dragging is never the only way; on the web the arrow keys move a focused card (left and right to the end of the next column), a screen reader is offered the same moves as actions, and every move is announced. `onMove(item, { fromColumn, toColumn, index })` runs once per real change (`index` is its place in the target column); a drag released over no column, or where it started, springs back and calls nothing. The board draws `items` as you pass them, so show a moved card in its new column at once: keep an optimistic map until your write lands and you refetch, and on failure drop it with a toast so the card returns. Props: `columns` (`[{ key, title }]`), `items`, `getColumn(item)` (its column key), `onMove`, `renderCard({ item, index, column, dragging })` (a card\'s content), `keyExtractor`, `getItemLabel`, `canDrag` (a boolean or per item, read on every render), `reorderInColumn` (default true; false when cards keep no order of their own, and a drop in its own column then changes nothing), `columnWidth` (the narrow width of a column, default 280), `labels` (as SortableList, plus `moveToColumn` "Move to {column}" and `movedToColumn` "{item} moved to {column}, position {position}"), `style`, `testID`, `accessibilityLabel`.',
3638
+ rnComponent: null,
3639
+ docsUrl: null,
3640
+ },
3577
3641
  ];
3578
3642
 
3579
3643
  const CATEGORIES = [
@@ -6254,7 +6318,15 @@ const CONTRACT = deepFreeze({
6254
6318
  // 1.201.0: additive (sc-12070, sc-12071, sc-12072) — new `<Grid>`,
6255
6319
  // `<Chart>` + `chart` theme scope + `chartColors`, `<PeoplePicker>`,
6256
6320
  // searchable `<Select>`; `useGroups` takes `ids`.
6257
- version: "1.201.0",
6321
+ // 1.202.0: additive (sc-12073, sc-12074, sc-12075, sc-12076) — new
6322
+ // `<DataTable>`, `<FilterBar>`, `<Form>` / `<FieldRow>` / `<FormSection>`
6323
+ // / `<FormActions>` / `<FormField>`, `<SortableList>`, `<Board>`;
6324
+ // `required` on TextField, Select, RadioGroup, PeoplePicker, FileUpload.
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",
6258
6330
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6259
6331
  hooks: HOOKS,
6260
6332
  primitives: PRIMITIVES,
@@ -0,0 +1,179 @@
1
+ // The `<DataTable>` rules without a renderer: which layout a width gets, how
2
+ // its columns read, and how rows sort. React-free, so both hosts and the SDK's
3
+ // own tests share one answer.
4
+
5
+ import { widthTier } from "./container-width.js";
6
+
7
+ /** The rows' scroll box stops here unless `maxHeight` says otherwise. */
8
+ export const DATA_TABLE_DEFAULT_MAX_HEIGHT = 480;
9
+ /** Placeholder rows while the first page loads. */
10
+ export const DATA_TABLE_SKELETON_ROWS = 3;
11
+ /** A sortable header is a button, so it keeps the touch-target floor. */
12
+ export const DATA_TABLE_HEADER_TARGET = 44;
13
+ export const DATA_TABLE_ALIGNS = Object.freeze(["left", "center", "right"]);
14
+ export const DATA_TABLE_LAYOUTS = Object.freeze({
15
+ narrow: "stacked",
16
+ medium: "cards",
17
+ wide: "columns",
18
+ });
19
+ /** The English words a sort is spoken with; a widget passes its own language. */
20
+ export const DEFAULT_SORT_LABELS = Object.freeze({
21
+ sort: "Sort",
22
+ ascending: "sorted ascending",
23
+ descending: "sorted descending",
24
+ });
25
+
26
+ const JUSTIFY = Object.freeze({ left: "flex-start", center: "center", right: "flex-end" });
27
+
28
+ function isText(value) {
29
+ return typeof value === "string" && value.length > 0;
30
+ }
31
+
32
+ function positive(value, fallback) {
33
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
34
+ }
35
+
36
+ /**
37
+ * The layout a MEASURED width calls for: stacked label/value cards when
38
+ * narrow, one card per row at medium, aligned columns when wide. An
39
+ * unmeasured width is "columns", as `widthTier(0)` is "wide".
40
+ */
41
+ export function dataTableLayout(width) {
42
+ return DATA_TABLE_LAYOUTS[widthTier(width)];
43
+ }
44
+
45
+ function normaliseColumn(entry) {
46
+ const column = typeof entry === "string" ? { key: entry } : entry;
47
+ if (!column || typeof column !== "object" || !isText(column.key)) return null;
48
+ return {
49
+ key: column.key,
50
+ label: column.label !== undefined && column.label !== null ? column.label : column.key,
51
+ flex: positive(column.flex, 1),
52
+ align: DATA_TABLE_ALIGNS.includes(column.align) ? column.align : "left",
53
+ minWidth: positive(column.minWidth, 0),
54
+ sortable: column.sortable === true,
55
+ render: typeof column.render === "function" ? column.render : null,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The columns as the table draws them. A bare string is a column of that
61
+ * key; an entry without a key is dropped; a repeated key keeps its first.
62
+ */
63
+ export function normaliseColumns(columns) {
64
+ if (!Array.isArray(columns)) return [];
65
+ const seen = new Set();
66
+ const out = [];
67
+ for (const entry of columns) {
68
+ const column = normaliseColumn(entry);
69
+ if (!column || seen.has(column.key)) continue;
70
+ seen.add(column.key);
71
+ out.push(column);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** A `{ key, direction }` sort, or null when it names no column. */
77
+ export function normaliseSort(sort) {
78
+ if (!sort || typeof sort !== "object" || !isText(sort.key)) return null;
79
+ return { key: sort.key, direction: sort.direction === "desc" ? "desc" : "asc" };
80
+ }
81
+
82
+ /** A header tap: a new column sorts ascending, the sorted one flips. */
83
+ export function nextSort(current, key) {
84
+ const now = normaliseSort(current);
85
+ const flip = now && now.key === key && now.direction === "asc";
86
+ return { key, direction: flip ? "desc" : "asc" };
87
+ }
88
+
89
+ /** The text a cell shows for a record value; nothing for an empty one. */
90
+ export function cellText(value) {
91
+ if (value === null || value === undefined) return "";
92
+ if (Array.isArray(value)) return value.map(cellText).filter(Boolean).join(", ");
93
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
94
+ return String(value);
95
+ }
96
+ if (typeof value === "object") {
97
+ for (const name of ["label", "name", "id"]) {
98
+ if (typeof value[name] === "string") return value[name];
99
+ }
100
+ }
101
+ return "";
102
+ }
103
+
104
+ function sortKey(value) {
105
+ if (typeof value === "number" && Number.isFinite(value)) return value;
106
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.getTime();
107
+ if (typeof value === "boolean") return value ? 1 : 0;
108
+ const text = cellText(value);
109
+ return text === "" ? null : text;
110
+ }
111
+
112
+ function compareKeys(a, b) {
113
+ if (typeof a === "number" && typeof b === "number") return a - b;
114
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
115
+ }
116
+
117
+ /**
118
+ * The rows in `sort` order, as a new array; stable, with empty values last
119
+ * in either direction. Without a sort, the rows as given.
120
+ */
121
+ export function sortRows(rows, sort) {
122
+ const list = Array.isArray(rows) ? rows : [];
123
+ const by = normaliseSort(sort);
124
+ if (!by) return list;
125
+ const sign = by.direction === "desc" ? -1 : 1;
126
+ return list
127
+ .map((row, index) => ({ row, index, key: sortKey(row && row[by.key]) }))
128
+ .sort((a, b) => {
129
+ if (a.key === null || b.key === null) {
130
+ return a.key === b.key ? a.index - b.index : a.key === null ? 1 : -1;
131
+ }
132
+ return sign * compareKeys(a.key, b.key) || a.index - b.index;
133
+ })
134
+ .map((entry) => entry.row);
135
+ }
136
+
137
+ /** A row's React key: its `keyField` value, else its position. */
138
+ export function rowKey(row, keyField, index) {
139
+ const value = row && typeof row === "object" ? row[keyField] : undefined;
140
+ return value !== undefined && value !== null && value !== "" ? String(value) : `row-${index}`;
141
+ }
142
+
143
+ /** A sortable column's sort state: "none", "ascending" or "descending". */
144
+ export function ariaSortFor(column, sort) {
145
+ const by = normaliseSort(sort);
146
+ if (!by || by.key !== column.key) return "none";
147
+ return by.direction === "desc" ? "descending" : "ascending";
148
+ }
149
+
150
+ /** A header's spoken name: its label, and the sort when it is the sorted one. */
151
+ export function sortSpokenLabel(label, column, sort, labels = DEFAULT_SORT_LABELS) {
152
+ const words = sortStateWords(column, sort, labels);
153
+ return words ? `${label}, ${words}` : label;
154
+ }
155
+
156
+ /** The words a sorted column's state is spoken with; "" when it is not sorted. */
157
+ export function sortStateWords(column, sort, labels = DEFAULT_SORT_LABELS) {
158
+ const state = ariaSortFor(column, sort);
159
+ if (state === "none") return "";
160
+ return state === "descending" ? labels.descending : labels.ascending;
161
+ }
162
+
163
+ /** The rows' scroll-box height: a positive number, else the default. */
164
+ export function resolveMaxHeight(maxHeight) {
165
+ return positive(maxHeight, DATA_TABLE_DEFAULT_MAX_HEIGHT);
166
+ }
167
+
168
+ /** How a column's content sits across its cell. */
169
+ export function justifyFor(align) {
170
+ return JUSTIFY[align] || JUSTIFY.left;
171
+ }
172
+
173
+ /**
174
+ * Who orders the rows. With `onSortChange` the widget re-queries (server
175
+ * sort) and the table draws the rows as given; without it the table sorts.
176
+ */
177
+ export function sortsClientSide(onSortChange) {
178
+ return typeof onSortChange !== "function";
179
+ }