@colixsystems/widget-sdk 0.245.0 → 0.247.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 +60 -2
- package/dist/button-tokens.js +154 -0
- package/dist/button-view.js +251 -93
- package/dist/button.js +5 -3
- package/dist/button.native.js +6 -4
- package/dist/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +132 -35
- package/dist/contract.js +132 -35
- package/dist/detail-parts-view.js +31 -2
- package/dist/file-image-logic.js +162 -0
- package/dist/file-image-source.js +71 -0
- package/dist/file-image-view.js +119 -0
- package/dist/file-image.js +8 -0
- package/dist/file-image.native.js +8 -0
- package/dist/form-control-logic.js +23 -0
- package/dist/form-control-tokens.js +25 -10
- package/dist/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +481 -20
- package/dist/index.js +14 -1
- package/dist/index.native.js +14 -1
- package/dist/menu-view.js +168 -0
- package/dist/overlay-tokens.js +50 -0
- package/dist/overlay-view.js +67 -24
- package/dist/overlay.native.js +3 -1
- package/dist/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +247 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- package/dist/popover-tokens.js +145 -0
- package/dist/popover-view.js +252 -0
- package/dist/popover.js +23 -0
- package/dist/popover.native.js +22 -0
- package/dist/radio-group-logic.js +61 -0
- package/dist/radio-group-tokens.js +62 -0
- package/dist/radio-group-view.js +262 -0
- package/dist/radio-group.js +12 -0
- package/dist/radio-group.native.js +7 -0
- package/dist/select-search-logic.js +150 -0
- package/dist/select-view.js +255 -43
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- package/dist/tabs-tokens.js +59 -0
- package/dist/tabs-view.js +9 -43
- package/dist/tooltip-view.js +137 -0
- package/dist/web-modal.js +95 -23
- package/package.json +1 -1
- package/src/contract.js +132 -35
package/README.md
CHANGED
|
@@ -84,7 +84,61 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.247.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.247.0 (contract 1.201.0)
|
|
90
|
+
|
|
91
|
+
**`<Grid>` lays out card grids, tile rows and calendar-style cells for you.** It measures its own unpadded box and balances the lines, so there is no lone card under a full row, no fifth stat tile running off the edge, and no 200px calendar day. `<Grid minCellWidth={240}>{items.map((item) => <Card key={item.id} style={{ flex: 1 }}>…</Card>)}</Grid>` fills every line but the last and pads that one so its cards line up; `<Grid balance="tiles">` spreads tiles evenly; `<Grid columns={7} cell={{ min: 32, max: 64 }} square gap="xs">` draws a month of square days capped at 64px and centred (a block wider than the space starts at the leading edge). Every cell stays mounted as lines change, so a cell keeps its state across a resize. Layouts it cannot express (masonry, spanning cells) can still be laid out by hand with `useContainerWidth()`.
|
|
92
|
+
|
|
93
|
+
**A themed chart: `<Chart>` and Design > Components > Charts.** `<Chart type data />` draws a bar (default), horizontal bar (`hbar`), line, area, donut or sparkline chart whose series colours, axis and gridline colours and bar corners come from the new `chart` theme scope; colours left unset follow the theme, primary first. It measures its own width, holds its place with a `Skeleton` while `loading`, shows an `EmptyState` when there is nothing to plot, adds a legend for a donut or several `series`, thins crowded labels, groups numbers for the app's language, and reads to a screen reader as one image with a summary and the values (`accessibilityLabel` replaces the summary, `moreText` translates its tail). `valueFormat="percent"` expects values already in percent. A chart type it does not draw (a scatter plot, a timeline) is still yours to draw with `react-native-svg`, in the same colours through `chartColors(theme)`.
|
|
94
|
+
|
|
95
|
+
```jsx
|
|
96
|
+
const { groups, loading } = useDatastoreAggregate(tableId, { groupBy: "city", sumColumn: "visits", order: "sum:desc", limit: 10 });
|
|
97
|
+
<Chart type="bar" loading={loading} valueFormat="compact"
|
|
98
|
+
data={groups.map((g) => ({ label: String(g.group), value: Number(g.sum) || 0 }))} />
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Searchable pickers: `<Select searchable>` and `<PeoplePicker>`.** A long `Select` can be searched: pass `searchable` to filter the options you hold, `onSearch` to fetch each query yourself (called with "" when the sheet opens and again once typing pauses), or an async `options` function and Select fetches, keeping only the latest answer. `<PeoplePicker source="users" | "groups" | "records">` does this for the workspace's people, groups or a table's records with avatar rows, server-side search and `excludeIds`, and names a saved choice that is not on the first page instead of showing its id (a malformed saved id reads "Unknown" without failing the rest). `useGroups({ ids })` names groups by id.
|
|
102
|
+
|
|
103
|
+
```jsx
|
|
104
|
+
<PeoplePicker label="Assignee" value={assignee} onValueChange={setAssignee} />
|
|
105
|
+
<PeoplePicker source="groups" multiple label="Teams" values={teams} onValuesChange={setTeams} />
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### What's new in 0.246.0 (contract 1.200.0)
|
|
109
|
+
|
|
110
|
+
**The standard components are the default, not a limit.** Every component description now says what it is for and when a request needs something it cannot draw — a colour-swatch picker, a tri-state tick box, a confirmation that asks you to type a name — in which case you build that one piece yourself from theme tokens, with the right accessibility role and state. Correctness rules are unchanged: screen-level surfaces still go through `<Overlay>`, irreversible actions are still confirmed, and formatted text is still markdown.
|
|
111
|
+
|
|
112
|
+
**Menus, popovers and tooltips anchored to their trigger.** `<Menu>` opens a list of actions from one control, `<Popover>` shows your own content beside it, and `<Tooltip>` names an icon-only button. They open beside the trigger on a wide screen, flip at the screen edge, become a bottom sheet on a phone, and close on the backdrop, Escape and Android back; no card or list clips them. A picked item's `onPress` runs once the menu has left the screen, so an item can open a `<ConfirmDialog>` that returns focus to the menu's button when it closes. On the web an anchored panel closes when the page scrolls beneath it. Menus are keyboard-navigable `menu`/`menuitem` lists; tooltips show on hover and focus on the web and on long press on touch. They follow Design > Components > Dropdown menus (`popover`), which gains `radius` and `shadow`; an unthemed `<Select>` is unchanged.
|
|
113
|
+
|
|
114
|
+
```jsx
|
|
115
|
+
<Menu trigger={<IconButton icon="more-horizontal" accessibilityLabel="More actions" />}
|
|
116
|
+
items={[{ label: "Edit", icon: "pencil", onPress: edit },
|
|
117
|
+
canDelete && { label: "Delete", icon: "trash-2", danger: true, onPress: askDelete }]} />
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Quiet buttons, icon buttons and busy states.** `<Button>` gains `variant="ghost"` and `variant="link"`, a `size` of `sm` (36px), `md` (44px, the default) or `lg` (52px), `busy` (a `<Spinner>` over the kept label; it cannot be pressed again) and `selected` for a toggle. `<IconButton icon accessibilityLabel>` is the same themed button as an icon-only square of at least 36px on both hosts. Both pass `onLongPress`, `aria-haspopup`, `aria-expanded` and their ref through, so they work as a `<Menu>`/`<Popover>` trigger and inside a `<Tooltip>`. Existing buttons render exactly as before.
|
|
121
|
+
|
|
122
|
+
```jsx
|
|
123
|
+
<Button title="Save" onPress={save} busy={saving} />
|
|
124
|
+
<Button title="Load more" variant="ghost" onPress={next} />
|
|
125
|
+
<IconButton icon="trash-2" accessibilityLabel="Delete order" color={theme.colors.danger} onPress={remove} />
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**One way to show a stored picture: `<FileImage>` and `<Avatar fileId>`.** Give `<FileImage>` a FILE column's value and it resolves the file with `useFilestoreFile`, draws a `<Skeleton>` in the app's Loading look while it loads, and shows an image glyph (or a blank box) instead of a broken image. Signed URLs expire, so a failed load refetches the file once before giving up. Pass `uri` for a URL you already hold. `<Avatar fileId>` does the same for a person's photo and keeps the initials if it cannot load; `url` callers are unchanged.
|
|
129
|
+
|
|
130
|
+
```jsx
|
|
131
|
+
<FileImage fileId={product.Photo} variant="card" aspectRatio={4 / 3} accessibilityLabel={product.Name} />
|
|
132
|
+
<Avatar name={member.Name} fileId={member.Photo} size="sm" />
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**`<RadioGroup>`: one choice from a short visible list.** It takes the form controls' `label`, `helper`, `error` and `disabled`, and options `{ value, label, description, icon, disabled }`. `variant="list"` (default) and `"card"` follow Design > Components > Inputs; `"segmented"` draws the Tabs scope's segmented strip. On the web the group is one tab stop, the arrows move the choice past disabled options and Space picks; on native a tap picks. A long list stays a `Select`.
|
|
136
|
+
|
|
137
|
+
```jsx
|
|
138
|
+
<RadioGroup variant="card" label="Delivery" value={speed} onValueChange={setSpeed}
|
|
139
|
+
options={[{ value: "standard", label: "Standard", description: "3-5 working days" },
|
|
140
|
+
{ value: "express", label: "Express", description: "Next working day" }]} />
|
|
141
|
+
```
|
|
88
142
|
|
|
89
143
|
### What's new in 0.245.0 (contract 1.199.0)
|
|
90
144
|
|
|
@@ -3032,13 +3086,17 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
3032
3086
|
- `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.
|
|
3033
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. Never hand-roll a row or a header row from `View` + `Pressable`.
|
|
3034
3088
|
- `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
|
+
- `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()`.
|
|
3035
3090
|
- `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`.
|
|
3036
3091
|
- `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.
|
|
3037
3092
|
- `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.
|
|
3038
3093
|
- `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
|
|
3039
3094
|
- `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
|
+
- `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
|
+
- `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.
|
|
3040
3097
|
- `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. Never hand-roll tabs from `Pressable` chips.
|
|
3041
3098
|
- `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
|
+
- `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`.
|
|
3042
3100
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
3043
3101
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
3044
3102
|
|
|
@@ -3068,7 +3126,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3068
3126
|
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
3069
3127
|
- **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.
|
|
3070
3128
|
- **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.
|
|
3071
|
-
- **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. **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.
|
|
3129
|
+
- **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.
|
|
3072
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.
|
|
3073
3131
|
- **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.
|
|
3074
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.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The variants, sizes and quiet looks behind `<Button>` and `<IconButton>`.
|
|
2
|
+
// Platform-free and React-free, so both hosts resolve one look and tests can
|
|
3
|
+
// pin it.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
|
|
7
|
+
export const BUTTON_VARIANTS = Object.freeze([
|
|
8
|
+
"primary",
|
|
9
|
+
"secondary",
|
|
10
|
+
"outlined",
|
|
11
|
+
"danger",
|
|
12
|
+
"ghost",
|
|
13
|
+
"link",
|
|
14
|
+
]);
|
|
15
|
+
export const BUTTON_SIZES = Object.freeze(["sm", "md", "lg"]);
|
|
16
|
+
export const DEFAULT_BUTTON_SIZE = "md";
|
|
17
|
+
|
|
18
|
+
// A quiet button has no chrome of its own, so the theme's fill, border,
|
|
19
|
+
// shadow and label colour (drawn for a filled button) never reach it.
|
|
20
|
+
const QUIET_VARIANTS = Object.freeze(["ghost", "link"]);
|
|
21
|
+
const THEME_PAINT_FIELDS = Object.freeze([
|
|
22
|
+
"background",
|
|
23
|
+
"gradient",
|
|
24
|
+
"textColor",
|
|
25
|
+
"borderColor",
|
|
26
|
+
"borderWidth",
|
|
27
|
+
"shadow",
|
|
28
|
+
"ringColor",
|
|
29
|
+
"ringWidth",
|
|
30
|
+
"dangerBackground",
|
|
31
|
+
"dangerBorderColor",
|
|
32
|
+
"dangerTextColor",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// The square an `<IconButton>` draws, and the glyph inside it.
|
|
36
|
+
export const ICON_BUTTON_SIZES = Object.freeze({
|
|
37
|
+
sm: Object.freeze({ box: 36, glyph: 16 }),
|
|
38
|
+
md: Object.freeze({ box: 44, glyph: 20 }),
|
|
39
|
+
lg: Object.freeze({ box: 52, glyph: 24 }),
|
|
40
|
+
});
|
|
41
|
+
// The touch target a native platform recommends; hitSlop reaches it on a
|
|
42
|
+
// device, while the web only ever has the box itself.
|
|
43
|
+
const NATIVE_TOUCH_TARGET = 44;
|
|
44
|
+
|
|
45
|
+
export function resolveButtonVariant(variant) {
|
|
46
|
+
return BUTTON_VARIANTS.includes(variant) ? variant : "primary";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveButtonSize(size) {
|
|
50
|
+
return BUTTON_SIZES.includes(size) ? size : DEFAULT_BUTTON_SIZE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isQuietLook(variant, selected) {
|
|
54
|
+
return selected === true || QUIET_VARIANTS.includes(variant);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A variant's own paint before the section, the theme or the caller move it.
|
|
59
|
+
* `selected` is a toggle's on state: the accent's quiet tint, whatever the
|
|
60
|
+
* variant.
|
|
61
|
+
*/
|
|
62
|
+
export function variantPaint(variant, colors, selected = false) {
|
|
63
|
+
if (selected) {
|
|
64
|
+
return {
|
|
65
|
+
container: { backgroundColor: colors.primarySoft || DEFAULT_THEME_TOKENS.colors.primarySoft },
|
|
66
|
+
ink: colors.onPrimarySoft || DEFAULT_THEME_TOKENS.colors.onPrimarySoft,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (variant === "secondary") {
|
|
70
|
+
return {
|
|
71
|
+
container: { backgroundColor: colors.secondary, borderWidth: 1, borderColor: colors.border },
|
|
72
|
+
ink: colors.onSecondary,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (variant === "danger") {
|
|
76
|
+
return {
|
|
77
|
+
container: { backgroundColor: colors.danger, borderWidth: 1, borderColor: colors.danger },
|
|
78
|
+
ink: colors.onPrimary,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (variant === "outlined") {
|
|
82
|
+
return {
|
|
83
|
+
container: { backgroundColor: "transparent", borderWidth: 1, borderColor: colors.primary },
|
|
84
|
+
ink: colors.primary,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (QUIET_VARIANTS.includes(variant)) {
|
|
88
|
+
return { container: { backgroundColor: "transparent" }, ink: colors.primary };
|
|
89
|
+
}
|
|
90
|
+
return { container: { backgroundColor: colors.primary }, ink: colors.onPrimary };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The theme-folded fields a quiet look keeps: its shape, weight, size and
|
|
95
|
+
* insets, but none of the paint the app's button look set for a filled
|
|
96
|
+
* button. A field the caller passed in `own` is always kept.
|
|
97
|
+
*/
|
|
98
|
+
export function quietButtonFields(folded, own) {
|
|
99
|
+
const caller = own || {};
|
|
100
|
+
const kept = { ...folded };
|
|
101
|
+
for (const key of THEME_PAINT_FIELDS) {
|
|
102
|
+
if (caller[key] === undefined) delete kept[key];
|
|
103
|
+
}
|
|
104
|
+
return kept;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The insets, minimum size and label size of a button at `size`. */
|
|
108
|
+
export function buttonSizeFrame(size, variant, theme) {
|
|
109
|
+
const { spacing, typography } = theme;
|
|
110
|
+
const frames = {
|
|
111
|
+
sm: {
|
|
112
|
+
frame: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, minHeight: 36, minWidth: 48 },
|
|
113
|
+
fontSize: typography.sizes.sm,
|
|
114
|
+
},
|
|
115
|
+
md: {
|
|
116
|
+
frame: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, minHeight: 44, minWidth: 64 },
|
|
117
|
+
fontSize: typography.sizes.sm,
|
|
118
|
+
},
|
|
119
|
+
lg: {
|
|
120
|
+
frame: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 52, minWidth: 80 },
|
|
121
|
+
fontSize: typography.sizes.md,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const { frame, fontSize } = frames[resolveButtonSize(size)];
|
|
125
|
+
// A link reads as text, so it keeps its height but drops the side insets.
|
|
126
|
+
if (variant === "link") return { frame: { ...frame, paddingHorizontal: 0, minWidth: 0 }, fontSize };
|
|
127
|
+
return { frame, fontSize };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The square box and glyph size of an `<IconButton>`. */
|
|
131
|
+
export function iconButtonFrame(size) {
|
|
132
|
+
return ICON_BUTTON_SIZES[resolveButtonSize(size)];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What extends a box smaller than the native touch target up to it. */
|
|
136
|
+
export function iconButtonHitSlop(box) {
|
|
137
|
+
if (box >= NATIVE_TOUCH_TARGET) return undefined;
|
|
138
|
+
const reach = (NATIVE_TOUCH_TARGET - box) / 2;
|
|
139
|
+
return { top: reach, bottom: reach, left: reach, right: reach };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The colour of a busy button's `<Spinner>`: the label's ink on a fill the
|
|
144
|
+
* button paints, else unset so the spinner wears `theme.colors.loader`.
|
|
145
|
+
*/
|
|
146
|
+
export function busySpinnerColor(fill, ink, gradient) {
|
|
147
|
+
const painted = Boolean(gradient) || (typeof fill === "string" && fill !== "transparent");
|
|
148
|
+
return painted ? ink : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The spinner diameter that sits inside a label of `fontSize`. */
|
|
152
|
+
export function busySpinnerSize(fontSize) {
|
|
153
|
+
return fontSize > 14 ? "md" : "sm";
|
|
154
|
+
}
|