@colixsystems/widget-sdk 0.247.0 → 0.248.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 +53 -5
- package/dist/board-auto-scroll.js +59 -0
- package/dist/board-view.js +327 -0
- package/dist/contract.cjs +70 -2
- package/dist/contract.js +70 -2
- package/dist/data-table-logic.js +179 -0
- package/dist/data-table-view.js +388 -0
- package/dist/data-table.js +22 -0
- package/dist/data-table.native.js +14 -0
- package/dist/drag-kit-view.js +310 -0
- package/dist/file-upload-view.js +13 -2
- package/dist/filter-bar-logic.js +143 -0
- package/dist/filter-bar-view.js +381 -0
- package/dist/filter-bar.js +15 -0
- package/dist/filter-bar.native.js +13 -0
- package/dist/form-control-logic.js +20 -3
- package/dist/form-control-view.js +18 -0
- package/dist/form-layout-logic.js +92 -0
- package/dist/form-view.js +268 -0
- package/dist/form.js +15 -0
- package/dist/form.native.js +9 -0
- package/dist/index.d.ts +372 -5
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/dist/menu-view.js +5 -2
- package/dist/people-picker-view.js +3 -0
- package/dist/radio-group-view.js +7 -3
- package/dist/select-view.js +7 -2
- package/dist/sortable-list-view.js +228 -0
- package/dist/sortable-logic.js +273 -0
- package/dist/sortable-tokens.js +120 -0
- package/dist/sortable-view.js +24 -0
- package/dist/sortable.js +39 -0
- package/dist/sortable.native.js +30 -0
- package/dist/text-field-view.js +7 -2
- package/package.json +1 -1
- package/src/contract.js +70 -2
package/README.md
CHANGED
|
@@ -84,7 +84,51 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.248.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.248.0 (contract 1.202.0)
|
|
90
|
+
|
|
91
|
+
**`<DataTable>`: draw a record list once and get all three width tiers.** Narrow boxes get stacked label/value cards, medium ones a card per row, wide ones aligned columns under a sticky header, in the app's Lists look. Headers are 44px buttons; a sort is described on the header and spoken once. Pass `sort` + `onSortChange` to re-query on a header tap (server sort), or leave them out and the table sorts the rows itself (numbers, text and `Date` values); a controlled `sort` without `onSortChange` is display-only. Rows scroll inside a scrolling page on every host, Android included.
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
<DataTable
|
|
95
|
+
columns={[{ key: "name", label: "Name", flex: 2, sortable: true },
|
|
96
|
+
{ key: "status", label: "Status", render: (row) => <Badge label={row.status} /> }]}
|
|
97
|
+
rows={data} loading={loading} onRowPress={(row) => open(row.id)} />
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**`<FilterBar>`: search and facet filters that lay themselves out.** On a wide or medium box it is one toolbar row (the search field, a compact dropdown per facet reading "Status: Any", an optional sort) with the applied filters as removable chips and Clear all; on a narrow box it is the search field and a "Filters · n" button that opens the facets in a sheet, applied on Apply, with the applied chips still beneath it. `onChange` fires once per edit and the query is reported once typing pauses; both values are controlled, so seed them from route params or `useWidgetRoute` and a copied link opens the list filtered. Every piece of copy is a prop you translate.
|
|
101
|
+
|
|
102
|
+
```jsx
|
|
103
|
+
<FilterBar query={q} onQueryChange={setQ}
|
|
104
|
+
facets={[{ key: "status", label: "Status", options }]} value={filters} onChange={setFilters}
|
|
105
|
+
sort={{ options: sorts, value: sortBy, onChange: setSortBy }} />
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Form layout: `<Form>`, `<FieldRow>`, `<FormSection>`, `<FormActions>`, `<FormField>`.** Wrap a widget's fields in `<Form>` and it lays them out for the width it is given: one to a line on a phone, two at medium, up to three on a desktop page, never wider than about 1000px and always centred. Pair short fields with `<FieldRow>`, group them with `<FormSection title>`, end with `<FormActions>` (secondary first, primary last), and put any control of your own in `<FormField label required helper error>` so it gets the same label, required mark and error as the built-in fields, named for a screen reader on both hosts. `TextField`, `Select`, `RadioGroup`, `PeoplePicker` and `FileUpload` take `required`, which draws a mark and adds "Required" to the spoken name (it does not validate); inside a `<FormField>` they draw one label.
|
|
109
|
+
|
|
110
|
+
```jsx
|
|
111
|
+
<Form>
|
|
112
|
+
<FieldRow>
|
|
113
|
+
<TextField label="First name" required value={first} onChangeText={setFirst} />
|
|
114
|
+
<TextField label="Last name" value={last} onChangeText={setLast} />
|
|
115
|
+
</FieldRow>
|
|
116
|
+
<FormField label="Date" required error={dateError}><DateTimePicker mode="date" value={date} onChange={setDate} /></FormField>
|
|
117
|
+
<FormActions>
|
|
118
|
+
<Button variant="secondary" title="Cancel" onPress={cancel} />
|
|
119
|
+
<Button title="Save" onPress={save} />
|
|
120
|
+
</FormActions>
|
|
121
|
+
</Form>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Drag and drop: `<SortableList>` and `<Board>`.** `<SortableList items onReorder renderItem>` reorders a list of `<ListRow>`s and `<Board columns items getColumn onMove renderCard>` moves `<Card>`s between columns, on both hosts. They own the grip handle, the lifted copy, the vacated slot, the soft-primary drop highlight, the spring back and a narrow board's sideways scroll. Dragging is never the only way: a tap, Enter, Space or a screen reader's click on the handle opens its move options, arrow keys move on the web, and a device's screen reader gets move actions. `onReorder` / `onMove` fire once per real change; a drag released where it started, cancelled, overtaken by a change to the items, or scrolled away from on the web springs back and calls nothing.
|
|
125
|
+
|
|
126
|
+
```jsx
|
|
127
|
+
<Board columns={[{ key: "todo", title: "To do" }, { key: "done", title: "Done" }]}
|
|
128
|
+
items={rows} getColumn={(row) => row.status} canDrag={canWrite}
|
|
129
|
+
onMove={(row, { toColumn }) => update(row.id, { status: toColumn })}
|
|
130
|
+
renderCard={({ item }) => <Text>{item.title}</Text>} />
|
|
131
|
+
```
|
|
88
132
|
|
|
89
133
|
### What's new in 0.247.0 (contract 1.201.0)
|
|
90
134
|
|
|
@@ -3084,9 +3128,12 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
3084
3128
|
- `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
|
|
3085
3129
|
- `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
|
|
3086
3130
|
- `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
|
|
3087
|
-
- `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel.
|
|
3131
|
+
- `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Use them by default for record rows and a table's header; build your own row when it needs what `ListRow` cannot do (a swipe-to-delete row, a row that expands in place).
|
|
3132
|
+
- `DataTable` — a list or table of records laid out for its own width, on both hosts. `<DataTable columns rows>` is stacked label/value cards when narrow, one card per row at medium and aligned columns under a sticky header when wide, built from `List` / `ListRow` / `ListHeader` and styled by the theme's `list` scope. A column is `{ key, label, flex, align, minWidth, sortable, render }`; `renderCell`, `onRowPress`, `keyField` (default `"id"`), `loading` and `emptyTitle` / `emptyMessage`, and `maxHeight` (480) complete it. A sortable header is a 44px button named by its column and described by its sort, and a change of sort is announced once; with `onSortChange` the widget orders the rows (a server sort) and the table draws them as given, otherwise it sorts them itself (a `Date` as a date). A controlled `sort` without `onSortChange` is display-only, its headers plain labels. Use it by default for records; editable cells, grouped rows, resizable columns or a pivot are yours to build from `ListHeader` + `ListRow`.
|
|
3088
3133
|
- `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
|
|
3089
3134
|
- `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
|
+
- `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
|
+
- `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`.
|
|
3090
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`.
|
|
3091
3138
|
- `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.
|
|
3092
3139
|
- `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.
|
|
@@ -3094,7 +3141,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
3094
3141
|
- `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, picker and search box. Like `TextField` they follow the theme's `muted` scope, Design > Components > Inputs (and `popover`, Dropdown menus, for the Select's option sheet; `SearchField` also reads `search`, Search fields, for its glyph position, corner radius, background, border colour and height, each falling back to Inputs) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level; with `multiple` it takes `values` / `onValuesChange`, ticks options in the same sheet and commits them on Done. Never hand-roll one from `Pressable` and `View`.
|
|
3095
3142
|
- `RadioGroup` — the themed pick-ONE of a short list that stays on screen. `<RadioGroup options value onValueChange variant>` with `variant` `"list"` (default) or `"card"`, both following `muted` (Design > Components > Inputs), or `"segmented"`, the `tabs` scope's segmented strip; options are `{ value, label, description, icon, disabled, testID }`, and it takes `label`, `helper`, `error` and `disabled`. Radio role and checked state on both hosts; on the web one tab stop, arrows move the choice. Use it by default for two to about six visible options; a long list is a `Select`, and a choice it cannot draw (a picture or a swatch per option) is yours to build, with the radio role.
|
|
3096
3143
|
- `PeoplePicker` — the themed pick of a workspace user, a group or a table's record. `<PeoplePicker source value onValueChange>` (`source` `"users"` (default), `"groups"` or `"records"` with `table`) is a searchable `Select`: it reads `useDirectory`, `useGroups` or `useDatastoreQuery`, searches on the server once typing pauses, leads each row with an `Avatar`, leaves out `excludeIds`, and looks up a saved value outside the first page once by id so the field names it, never showing the id. A `Select` of your own options turns searchable with `searchable` (filters what you pass), `onSearch(query)` with `loading`, or an async `options(query)`; `selectedOptions` names saved values the page does not hold.
|
|
3097
|
-
- `
|
|
3144
|
+
- `FilterBar` — a list's search and facet filters that lay themselves out for their own width. `<FilterBar query onQueryChange facets value onChange sort>`: medium and wide are one toolbar row (a `SearchField`, a compact `Select` per facet reading "Status: Any", the sort); narrow, or a row whose dropdowns would not fit, is the search field and one "Filters · n" button opening the facets in a `Modal` sheet that applies on Apply (its Clear empties the facets and puts the sort on its first option). At every width each applied facet is a removable `Chip` beneath, with a "Clear all". A facet is `{ key, label, options, multiple, searchable }`; `onChange(next)` fires once per edit, `onQueryChange` once typing pauses (`queryDebounceMs`, default 250). Both are controlled, so seed them from route params or `useWidgetRoute`. Its parts keep their own theme scopes; copy props default to English. Use it by default; a filter it cannot draw (a date range, a price slider) is yours to build in the same shape.
|
|
3145
|
+
- `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Use it by default to switch panels; build your own strip when it needs what `Tabs` cannot do (tabs that scroll a long page to its sections, a tab showing a live preview).
|
|
3098
3146
|
- `Chip`, `ChipGroup` — the themed filter chip, tag or pill. `<Chip label selected onPress onRemove disabled>` follows the theme's `chip` scope; `<ChipGroup>` spaces and wraps its chips and, with `selection="single"`, announces them as one choice. Never hand-roll a chip from `Pressable` + a pill radius.
|
|
3099
3147
|
- `Chart` — the themed chart, drawn with `react-native-svg` on both hosts. `<Chart type data series valueFormat height loading>` with `type` `"bar"` (default), `"hbar"`, `"line"`, `"area"`, `"donut"` or `"sparkline"`; `data` is `[{ label, value }]`, or one number per `series` key. It measures its own width, shows a `Skeleton` while `loading` and an `EmptyState` with no data, draws a legend for a donut or several series, and is one image to a screen reader, labelled with a summary and the values. Its series colours, axis and gridline colours and bar corners follow the theme's `chart` scope (Design > Components > Charts), falling back to the theme's colours. Use it by default, fed by `useDatastoreAggregate`; a chart it does not draw (a scatter plot, a timeline) is yours to draw with `react-native-svg`.
|
|
3100
3148
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
@@ -3127,9 +3175,9 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3127
3175
|
- **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.
|
|
3128
3176
|
- **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.
|
|
3129
3177
|
- **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.
|
|
3130
|
-
- **Compose forms — pair fields into rows, don't stack one per row.** 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. Put any other icon or 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.
|
|
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. Put any other icon or 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.
|
|
3131
3179
|
- **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.
|
|
3132
|
-
- **Drag and drop — show what is being dragged.** 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.
|
|
3180
|
+
- **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.
|
|
3133
3181
|
- **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).
|
|
3134
3182
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a square photo tile, a `16 / 9` photo card, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame. When an item has a photograph, make it the card's background with the title on it — `<Card image={url} scrim="bottom">` with a function child for the colours — rather than a small picture with its text in a box underneath; keep the stacked form for a card whose text is long or dense.
|
|
3135
3183
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A narrow `<Board>` scrolls its columns sideways; holding a card at either
|
|
2
|
+
// edge keeps scrolling them under it, so a far column is reachable by drag.
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { clampScroll } from "./sortable-logic.js";
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 16;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ current: any }} scrollRef — the board's horizontal ScrollView.
|
|
11
|
+
* @param {() => void} onScrolled — re-aims the drag after each scroll step.
|
|
12
|
+
*/
|
|
13
|
+
export function useAutoScroll(scrollRef, onScrolled) {
|
|
14
|
+
const offset = React.useRef(0);
|
|
15
|
+
const sizes = React.useRef({ content: 0, viewport: 0 });
|
|
16
|
+
const timer = React.useRef(null);
|
|
17
|
+
const step = React.useRef(0);
|
|
18
|
+
const latest = React.useRef(onScrolled);
|
|
19
|
+
latest.current = onScrolled;
|
|
20
|
+
|
|
21
|
+
const stop = () => {
|
|
22
|
+
if (timer.current !== null) clearInterval(timer.current);
|
|
23
|
+
timer.current = null;
|
|
24
|
+
step.current = 0;
|
|
25
|
+
};
|
|
26
|
+
const tick = () => {
|
|
27
|
+
const next = clampScroll(offset.current + step.current, sizes.current.content, sizes.current.viewport);
|
|
28
|
+
// At the edge, or the scroller is gone (the board went wide): nothing to do.
|
|
29
|
+
if (next === offset.current || !scrollRef.current) {
|
|
30
|
+
stop();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
scrollRef.current.scrollTo({ x: next, animated: false });
|
|
34
|
+
offset.current = next;
|
|
35
|
+
latest.current();
|
|
36
|
+
};
|
|
37
|
+
const aim = (nextStep) => {
|
|
38
|
+
step.current = nextStep;
|
|
39
|
+
if (nextStep === 0) stop();
|
|
40
|
+
else if (timer.current === null) timer.current = setInterval(tick, TICK_MS);
|
|
41
|
+
};
|
|
42
|
+
React.useEffect(() => stop, []);
|
|
43
|
+
|
|
44
|
+
const scrollProps = {
|
|
45
|
+
ref: scrollRef,
|
|
46
|
+
horizontal: true,
|
|
47
|
+
scrollEventThrottle: 16,
|
|
48
|
+
onScroll: (event) => {
|
|
49
|
+
offset.current = event.nativeEvent.contentOffset.x;
|
|
50
|
+
},
|
|
51
|
+
onContentSizeChange: (width) => {
|
|
52
|
+
sizes.current.content = width;
|
|
53
|
+
},
|
|
54
|
+
onLayout: (event) => {
|
|
55
|
+
sizes.current.viewport = event.nativeEvent.layout.width;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return { offset, aim, stop, scrollProps };
|
|
59
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// The ONE implementation behind `<Board>`: cards the user moves between
|
|
2
|
+
// columns (a kanban, a pipeline) by dragging a handle, or by its move options
|
|
3
|
+
// and arrow keys. Each card is the app's `<Card>`, so it wears the theme's
|
|
4
|
+
// `card` scope. On a narrow box the columns scroll sideways.
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { useContainerWidth, useHostTheme } from "./hooks.js";
|
|
8
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
9
|
+
import { widthTier } from "./container-width.js";
|
|
10
|
+
import { textRoleStyle } from "./text-roles.js";
|
|
11
|
+
import { measureNode } from "./drag-kit-view.js";
|
|
12
|
+
import { useAutoScroll } from "./board-auto-scroll.js";
|
|
13
|
+
import {
|
|
14
|
+
BOARD_COLUMN_WIDTH,
|
|
15
|
+
autoScrollStep,
|
|
16
|
+
boardLanding,
|
|
17
|
+
boardLanes,
|
|
18
|
+
boardMoveActions,
|
|
19
|
+
canDragItem,
|
|
20
|
+
columnAt,
|
|
21
|
+
dropLineOffset,
|
|
22
|
+
fillLabel,
|
|
23
|
+
findCard,
|
|
24
|
+
indexInColumn,
|
|
25
|
+
itemLabel,
|
|
26
|
+
keyboardStep,
|
|
27
|
+
landingTop,
|
|
28
|
+
resolveSortableLabels,
|
|
29
|
+
} from "./sortable-logic.js";
|
|
30
|
+
import {
|
|
31
|
+
HIDDEN,
|
|
32
|
+
HIDDEN_FROM_READERS,
|
|
33
|
+
PROXY_SCALE,
|
|
34
|
+
columnContentInset,
|
|
35
|
+
columnStyle,
|
|
36
|
+
dropLineStyle,
|
|
37
|
+
proxyStyle,
|
|
38
|
+
resolveSortableTokens,
|
|
39
|
+
slotStyle,
|
|
40
|
+
} from "./sortable-tokens.js";
|
|
41
|
+
|
|
42
|
+
const h = React.createElement;
|
|
43
|
+
const HEADER_MIN_HEIGHT = 28;
|
|
44
|
+
|
|
45
|
+
const sameSpot = (a, b) => a === b || (a !== null && b !== null && a.column === b.column && a.index === b.index);
|
|
46
|
+
|
|
47
|
+
// Where a column's cards start: the bottom of its header, from the column's top.
|
|
48
|
+
function headerBottom(data, column) {
|
|
49
|
+
const header = data.headers[column];
|
|
50
|
+
return header ? header.y + header.height - data.columns[column].y : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function measureBoard({ rootRef, columnRefs, headerRefs, cardRefs, lanes, key }) {
|
|
54
|
+
const from = findCard(lanes, key);
|
|
55
|
+
if (!from) return Promise.resolve(null);
|
|
56
|
+
const measureAll = (nodes) => Promise.all(nodes.map(measureNode));
|
|
57
|
+
return Promise.all([
|
|
58
|
+
measureNode(rootRef.current),
|
|
59
|
+
measureAll(lanes.map((lane, i) => columnRefs.current[i])),
|
|
60
|
+
measureAll(lanes.map((lane, i) => headerRefs.current[i])),
|
|
61
|
+
Promise.all(lanes.map((lane) => measureAll(lane.map((card) => cardRefs.current.get(card.key))))),
|
|
62
|
+
]).then(([root, columns, headers, cards]) => {
|
|
63
|
+
const own = cards[from.column][from.index];
|
|
64
|
+
if (!root || !own) return null;
|
|
65
|
+
const origin = { left: own.x - root.x, top: own.y - root.y, width: own.width };
|
|
66
|
+
return { key, from, root, columns, headers, cards, own, origin, resting: null };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Bind the shared board to one platform's primitives.
|
|
72
|
+
*
|
|
73
|
+
* @param {{View: any, Text: any, ScrollView: any, Animated: any, Card: any}} rn
|
|
74
|
+
* @param {ReturnType<import("./drag-kit-view.js").makeDragKit>} kit
|
|
75
|
+
*/
|
|
76
|
+
export function makeBoard(rn, kit) {
|
|
77
|
+
const { View, Text, ScrollView, Animated, Card } = rn;
|
|
78
|
+
const { HandleMark, moveHandle, useAnnouncer, useDragSession, useFocusAfterMove } = kit;
|
|
79
|
+
|
|
80
|
+
// The card's handle beside the widget's own content, on the app's card.
|
|
81
|
+
function CardFace({ handle, gap, children }) {
|
|
82
|
+
return h(
|
|
83
|
+
Card,
|
|
84
|
+
null,
|
|
85
|
+
h(
|
|
86
|
+
View,
|
|
87
|
+
{ style: { flexDirection: "row", alignItems: "flex-start", gap } },
|
|
88
|
+
handle,
|
|
89
|
+
h(View, { style: { flex: 1, minWidth: 0 } }, children),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ColumnHeader({ title, count, headerRef, testID, theme, gap }) {
|
|
95
|
+
return h(
|
|
96
|
+
View,
|
|
97
|
+
{ ref: headerRef, testID, style: { flexDirection: "row", alignItems: "center", gap, minHeight: HEADER_MIN_HEIGHT } },
|
|
98
|
+
h(Text, { style: [textRoleStyle("subtitle", theme.typography, theme.colors), { flex: 1 }], numberOfLines: 1 }, title),
|
|
99
|
+
h(Text, { style: textRoleStyle("caption", theme.typography, theme.colors) }, String(count)),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cards in columns the user moves them between. `onMove(item, { fromColumn,
|
|
105
|
+
* toColumn, index })` runs once per real change; a drag released over no
|
|
106
|
+
* column, or where it started, springs back and calls nothing.
|
|
107
|
+
*/
|
|
108
|
+
function Board({
|
|
109
|
+
columns,
|
|
110
|
+
items,
|
|
111
|
+
getColumn,
|
|
112
|
+
onMove,
|
|
113
|
+
renderCard,
|
|
114
|
+
keyExtractor,
|
|
115
|
+
getItemLabel,
|
|
116
|
+
canDrag = true,
|
|
117
|
+
reorderInColumn = true,
|
|
118
|
+
columnWidth = BOARD_COLUMN_WIDTH,
|
|
119
|
+
labels,
|
|
120
|
+
style,
|
|
121
|
+
testID,
|
|
122
|
+
accessibilityLabel,
|
|
123
|
+
}) {
|
|
124
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
125
|
+
const tokens = resolveSortableTokens(theme);
|
|
126
|
+
const words = resolveSortableLabels(labels);
|
|
127
|
+
const specs = Array.isArray(columns) ? columns : [];
|
|
128
|
+
const lanes = boardLanes(specs, Array.isArray(items) ? items : [], getColumn, keyExtractor);
|
|
129
|
+
const order = lanes.map((lane) => lane.map((card) => card.key).join("\u0000")).join("\u0001");
|
|
130
|
+
const [width, onLayout] = useContainerWidth();
|
|
131
|
+
const narrow = widthTier(width) === "narrow";
|
|
132
|
+
const rootRef = React.useRef(null);
|
|
133
|
+
const scrollRef = React.useRef(null);
|
|
134
|
+
const columnRefs = React.useRef([]);
|
|
135
|
+
const headerRefs = React.useRef([]);
|
|
136
|
+
const cardRefs = React.useRef(new Map());
|
|
137
|
+
const handles = React.useRef(new Map());
|
|
138
|
+
const pendingFocus = useFocusAfterMove(handles);
|
|
139
|
+
const [announce, liveRegion] = useAnnouncer();
|
|
140
|
+
|
|
141
|
+
// Presses faster than the widget re-renders would repeat the last move.
|
|
142
|
+
const lastCommit = React.useRef(null);
|
|
143
|
+
const commit = (key, column, index) => {
|
|
144
|
+
const from = findCard(lanes, key);
|
|
145
|
+
const at = boardLanding(lanes, from, column, index, reorderInColumn);
|
|
146
|
+
if (at === null) return false;
|
|
147
|
+
const move = `${order}\u0002${key}\u0002${column}\u0002${at}`;
|
|
148
|
+
if (lastCommit.current === move) return false;
|
|
149
|
+
lastCommit.current = move;
|
|
150
|
+
const { item } = lanes[from.column][from.index];
|
|
151
|
+
if (typeof onMove === "function") {
|
|
152
|
+
onMove(item, { fromColumn: specs[from.column].key, toColumn: specs[column].key, index: at });
|
|
153
|
+
}
|
|
154
|
+
const name = itemLabel(item, key, getItemLabel);
|
|
155
|
+
announce(fillLabel(words.movedToColumn, { item: name, column: specs[column].title, position: at + 1 }));
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
const moveKey = (key, column, index) => {
|
|
159
|
+
if (commit(key, column, index)) pendingFocus.current = key;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let session = null;
|
|
163
|
+
const scroller = useAutoScroll(scrollRef, () => session && session.retrack());
|
|
164
|
+
const pointerX = (data, gesture) => data.own.x + data.own.width / 2 + gesture.dx;
|
|
165
|
+
session = useDragSession({
|
|
166
|
+
sameTarget: sameSpot,
|
|
167
|
+
rootRef,
|
|
168
|
+
onCancel: scroller.stop,
|
|
169
|
+
measure: (key) =>
|
|
170
|
+
measureBoard({ rootRef, columnRefs, headerRefs, cardRefs, lanes, key }).then((data) =>
|
|
171
|
+
data ? { ...data, order, scrolledFrom: scroller.offset.current } : null,
|
|
172
|
+
),
|
|
173
|
+
// Frames were measured before any scroll; the pointer is shifted back by it.
|
|
174
|
+
track: (data, gesture) => {
|
|
175
|
+
const column = columnAt(data.columns, pointerX(data, gesture) + scroller.offset.current - data.scrolledFrom);
|
|
176
|
+
if (column === -1) return null;
|
|
177
|
+
const sameColumn = column === data.from.column;
|
|
178
|
+
if (sameColumn && !reorderInColumn) return null;
|
|
179
|
+
const others = data.cards[column].filter((frame, i) => !(sameColumn && i === data.from.index));
|
|
180
|
+
const index = reorderInColumn ? indexInColumn(others, data.own.y + data.own.height / 2 + gesture.dy) : others.length;
|
|
181
|
+
if (sameColumn && index === data.from.index) return null;
|
|
182
|
+
return { column, index, others };
|
|
183
|
+
},
|
|
184
|
+
afterMove: (data, gesture) => {
|
|
185
|
+
scroller.aim(narrow ? autoScrollStep(pointerX(data, gesture), data.root.x, data.root.x + data.root.width) : 0);
|
|
186
|
+
},
|
|
187
|
+
drop: (data, target) => {
|
|
188
|
+
scroller.stop();
|
|
189
|
+
if (!target || !commit(data.key, target.column, target.index)) return null;
|
|
190
|
+
const column = data.columns[target.column];
|
|
191
|
+
const top = landingTop(headerBottom(data, target.column), target.others, target.index, tokens.cardGap);
|
|
192
|
+
const scrolled = scroller.offset.current - data.scrolledFrom;
|
|
193
|
+
return { x: column.x - scrolled + columnContentInset(tokens) - data.own.x, y: column.y + top - data.own.y };
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const { drag } = session;
|
|
197
|
+
// Frames measured for another order would aim and commit at the wrong cards.
|
|
198
|
+
const stale = drag !== null && !drag.settling && drag.data.order !== order;
|
|
199
|
+
React.useEffect(() => {
|
|
200
|
+
if (stale) session.cancel();
|
|
201
|
+
});
|
|
202
|
+
React.useEffect(() => {
|
|
203
|
+
if (!narrow) scroller.stop();
|
|
204
|
+
}, [narrow]);
|
|
205
|
+
|
|
206
|
+
const handleFor = ({ item, key }, column, index) => {
|
|
207
|
+
const counts = lanes.map((lane) => lane.length);
|
|
208
|
+
const actions = boardMoveActions({ columns: specs, columnIndex: column, index, counts, reorderInColumn, labels: words });
|
|
209
|
+
return moveHandle({
|
|
210
|
+
itemKey: key,
|
|
211
|
+
name: itemLabel(item, key, getItemLabel),
|
|
212
|
+
actions,
|
|
213
|
+
run: (name) => {
|
|
214
|
+
const action = actions.find((a) => a.name === name);
|
|
215
|
+
if (action) moveKey(key, action.column, action.index);
|
|
216
|
+
},
|
|
217
|
+
onKey: (pressed) => {
|
|
218
|
+
const step = keyboardStep(pressed, true);
|
|
219
|
+
if (step && step.column !== undefined) {
|
|
220
|
+
const next = column + step.column;
|
|
221
|
+
if (next >= 0 && next < lanes.length) moveKey(key, next, lanes[next].length);
|
|
222
|
+
} else if (step) {
|
|
223
|
+
moveKey(key, column, index + step.by);
|
|
224
|
+
}
|
|
225
|
+
return step !== null;
|
|
226
|
+
},
|
|
227
|
+
session,
|
|
228
|
+
handles,
|
|
229
|
+
color: tokens.handleColor,
|
|
230
|
+
words,
|
|
231
|
+
testID,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const face = (card, column, index, handle, isCopy) =>
|
|
236
|
+
h(
|
|
237
|
+
CardFace,
|
|
238
|
+
{ handle, gap: tokens.cardGap },
|
|
239
|
+
typeof renderCard === "function"
|
|
240
|
+
? renderCard({ item: card.item, index, column: specs[column].key, dragging: isCopy })
|
|
241
|
+
: h(Text, { style: textRoleStyle("subtitle", theme.typography, theme.colors) }, itemLabel(card.item, card.key, getItemLabel)),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// The held card keeps its place, its content unseen over a quiet slot.
|
|
245
|
+
const cardNode = (card, column, index) => {
|
|
246
|
+
const held = drag !== null && drag.key === card.key;
|
|
247
|
+
const handle = canDragItem(canDrag, card.item) ? handleFor(card, column, index) : null;
|
|
248
|
+
return h(
|
|
249
|
+
View,
|
|
250
|
+
{
|
|
251
|
+
key: card.key,
|
|
252
|
+
ref: (node) => (node ? cardRefs.current.set(card.key, node) : cardRefs.current.delete(card.key)),
|
|
253
|
+
testID: testID ? `${testID}-card-${card.key}` : undefined,
|
|
254
|
+
style: held && !drag.settling ? slotStyle(tokens, "vacated") : null,
|
|
255
|
+
},
|
|
256
|
+
h(View, { style: held ? HIDDEN : null }, face(card, column, index, handle, false)),
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const target = drag && !drag.settling && !stale ? drag.target : null;
|
|
261
|
+
const columnNodes = lanes.map((lane, column) => {
|
|
262
|
+
const spec = specs[column];
|
|
263
|
+
const isTarget = target !== null && target.column === column;
|
|
264
|
+
const line = isTarget
|
|
265
|
+
? dropLineOffset(drag.data.columns[column], target.others, target.index, headerBottom(drag.data, column), tokens.cardGap)
|
|
266
|
+
: null;
|
|
267
|
+
return h(
|
|
268
|
+
View,
|
|
269
|
+
{
|
|
270
|
+
key: String(spec.key),
|
|
271
|
+
ref: (node) => {
|
|
272
|
+
columnRefs.current[column] = node;
|
|
273
|
+
},
|
|
274
|
+
role: "group",
|
|
275
|
+
accessibilityLabel: spec.title,
|
|
276
|
+
testID: testID ? `${testID}-column-${spec.key}` : undefined,
|
|
277
|
+
style: [columnStyle(tokens, isTarget), narrow ? { width: columnWidth } : { flex: 1, minWidth: 0 }],
|
|
278
|
+
},
|
|
279
|
+
h(ColumnHeader, {
|
|
280
|
+
title: spec.title,
|
|
281
|
+
count: lane.length,
|
|
282
|
+
headerRef: (node) => {
|
|
283
|
+
headerRefs.current[column] = node;
|
|
284
|
+
},
|
|
285
|
+
testID: testID ? `${testID}-column-${spec.key}-header` : undefined,
|
|
286
|
+
theme,
|
|
287
|
+
gap: tokens.cardGap,
|
|
288
|
+
}),
|
|
289
|
+
...lane.map((card, index) => cardNode(card, column, index)),
|
|
290
|
+
line !== null ? h(View, { style: dropLineStyle(tokens, line), testID: testID ? `${testID}-drop-line` : undefined }) : null,
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const row = { flexDirection: "row", gap: tokens.columnGap };
|
|
295
|
+
const columnsBox = narrow
|
|
296
|
+
? h(ScrollView, { ...scroller.scrollProps, contentContainerStyle: row }, ...columnNodes)
|
|
297
|
+
: h(View, { style: row }, ...columnNodes);
|
|
298
|
+
|
|
299
|
+
const heldAt = drag ? findCard(lanes, drag.key) : null;
|
|
300
|
+
const proxy = heldAt
|
|
301
|
+
? h(
|
|
302
|
+
Animated.View,
|
|
303
|
+
{
|
|
304
|
+
...HIDDEN_FROM_READERS,
|
|
305
|
+
testID: testID ? `${testID}-proxy` : undefined,
|
|
306
|
+
style: [
|
|
307
|
+
proxyStyle(tokens, drag.data.origin, false),
|
|
308
|
+
{ transform: [...session.pan.getTranslateTransform(), { scale: PROXY_SCALE }] },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
face(lanes[heldAt.column][heldAt.index], heldAt.column, heldAt.index, h(HandleMark, { color: tokens.handleColor }), true),
|
|
312
|
+
)
|
|
313
|
+
: null;
|
|
314
|
+
|
|
315
|
+
return h(
|
|
316
|
+
View,
|
|
317
|
+
{ ref: rootRef, testID, accessibilityLabel, style: [{ alignSelf: "stretch" }, style] },
|
|
318
|
+
// Measured without the board's own padding, which `style` may carry.
|
|
319
|
+
h(View, { onLayout }, columnsBox),
|
|
320
|
+
proxy,
|
|
321
|
+
liveRegion,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
Board.displayName = "Board";
|
|
326
|
+
return Board;
|
|
327
|
+
}
|