@colixsystems/widget-sdk 0.170.0 → 0.172.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 +52 -2
- package/dist/contract.cjs +50 -2
- package/dist/contract.js +50 -2
- package/dist/detail-parts-tokens.js +91 -0
- package/dist/detail-parts-view.js +392 -0
- package/dist/detail-parts.js +15 -0
- package/dist/detail-parts.native.js +15 -0
- package/dist/index.d.ts +118 -1
- package/dist/index.js +4 -1
- package/dist/index.native.js +4 -1
- package/dist/initials.js +17 -0
- package/dist/list-tokens.js +31 -0
- package/dist/list-view.js +84 -18
- package/dist/list.js +3 -3
- package/dist/list.native.js +4 -4
- package/dist/webbundle.js +4 -0
- package/package.json +1 -1
- package/src/webbundle.js +4 -0
package/README.md
CHANGED
|
@@ -73,7 +73,56 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.172.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**.
|
|
77
|
+
|
|
78
|
+
### What's new in 0.172.0 (contract 1.133.0)
|
|
79
|
+
|
|
80
|
+
**Tables whose rows use their full width, under a header that lines up: `ListHeader`, and a `ListRow` whose `View` child spans the row.** A `ListRow` given its own content used that content as-is, beside `leading` and `trailing` in a horizontal row. So a single row `View` of flex cells shrank to the width of its text and squeezed its cells into the left of the row, while a hand-built header above spread across the full width. A single `View` child now gets the same flexible slot the title block has, spanning the row between `leading` and `trailing`. Anything else (several cells, a component that renders cells, a lone `Text`) sits in the row exactly as before.
|
|
81
|
+
|
|
82
|
+
`<ListHeader>` labels a table's columns. It takes the same horizontal padding, gap and side edges as `<ListRow>`, from the same Theme Settings > Lists values, so a label given the same `flex` as its row cell sits over that column however the theme changes. A header `View` with its own padding cannot do that.
|
|
83
|
+
|
|
84
|
+
```jsx
|
|
85
|
+
const label = textRoleStyle("overline", theme.typography, theme.colors);
|
|
86
|
+
const body = textRoleStyle("body", theme.typography, theme.colors);
|
|
87
|
+
<ScrollView style={{ flex: 1 }} stickyHeaderIndices={[0]}>
|
|
88
|
+
<ListHeader style={{ backgroundColor: theme.colors.surface }}>
|
|
89
|
+
<Text numberOfLines={1} style={[label, { flex: 2, minWidth: 0 }]}>Customer</Text>
|
|
90
|
+
<Text numberOfLines={1} style={[label, { flex: 1, minWidth: 0 }]}>Status</Text>
|
|
91
|
+
</ListHeader>
|
|
92
|
+
<List>
|
|
93
|
+
{orders.map((o) => (
|
|
94
|
+
<ListRow key={o.id} onPress={() => open(o)}>
|
|
95
|
+
<Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{o.customer}</Text>
|
|
96
|
+
<Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{o.status}</Text>
|
|
97
|
+
</ListRow>
|
|
98
|
+
))}
|
|
99
|
+
</List>
|
|
100
|
+
</ScrollView>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Give the header and the rows the same shape: the same cells, the same `flex` per column, and `minWidth: 0` on every cell. Pass the header the same list overrides you pass the `<List>` (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`). Make it the first child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`: it stays in view, and it shares the rows' width beside a scrollbar, which a header outside the scroller does not. Give it the surface it sits on as its background so rows do not show through. When rows carry a `leading` or `trailing` node, pass the header a spacer of the same width in that slot. A selected row's accent bar now takes its width from the row's inset, so its cells stay under their labels too (unless the widget sets the row's padding through `style`, which is left exactly as it asked).
|
|
104
|
+
|
|
105
|
+
Installed widgets pick up the row fix with no rebuild on the web: the Player hands every widget the host's own SDK. An exported app picks it up on its next export.
|
|
106
|
+
|
|
107
|
+
### What's new in 0.171.0 (contract 1.132.0)
|
|
108
|
+
|
|
109
|
+
**`Badge`, `Avatar`, `Progress`, `Stat` and `Divider` — the small parts a record row is made of.** A custom widget listing records had no themed status pill, avatar or progress bar, so it drew its own and never matched the built-ins. These are now SDK components, and the built-in Badge, Avatar, Progress, Stat and Divider widgets render through them:
|
|
110
|
+
|
|
111
|
+
```jsx
|
|
112
|
+
<ListRow
|
|
113
|
+
title={order.title}
|
|
114
|
+
leading={<Avatar name={order.owner.label} size="sm" />}
|
|
115
|
+
trailing={<Badge label={order.status} tone={order.status === "Overdue" ? "danger" : "success"} />}
|
|
116
|
+
/>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **`<Badge>`** is a record's state (Active, Paid, Overdue) beside the thing it describes. `tone` is `neutral` | `info` | `success` | `warning` | `danger`, tinted from the app's theme; the label always shows.
|
|
120
|
+
- **`<Avatar>`** is a person: a picture, else the name's initials, else a person glyph. `size` is `sm` | `md` | `lg` | `xl` or a number of px.
|
|
121
|
+
- **`<Progress>`** is real progress toward a goal, as a `bar` or a `ring`; the percent is clamped to 0-100 and printed.
|
|
122
|
+
- **`<Stat>`** displays a headline figure you computed, with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
|
|
123
|
+
- **`<Divider>`** splits two groups inside one surface, optionally captioned. A `<List>` and a `<Card>` already draw their own edges.
|
|
124
|
+
|
|
125
|
+
`initialsFor(name)` is exported too. Additive: the built-in widgets render as before.
|
|
77
126
|
|
|
78
127
|
### What's new in 0.170.0 (contract 1.131.0)
|
|
79
128
|
|
|
@@ -1869,7 +1918,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1869
1918
|
- `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`.
|
|
1870
1919
|
- `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`).
|
|
1871
1920
|
- `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.
|
|
1872
|
-
- `List`, `ListRow`, `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; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row from `View` + `Pressable`.
|
|
1921
|
+
- `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`.
|
|
1922
|
+
- `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`.
|
|
1873
1923
|
- `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 Theme Settings (Cards, Buttons, Muted); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
|
|
1874
1924
|
- `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.
|
|
1875
1925
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
package/dist/contract.cjs
CHANGED
|
@@ -2345,7 +2345,14 @@ const PRIMITIVES = [
|
|
|
2345
2345
|
{
|
|
2346
2346
|
name: "ListRow",
|
|
2347
2347
|
description:
|
|
2348
|
-
'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block,
|
|
2348
|
+
'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
|
|
2349
|
+
rnComponent: null,
|
|
2350
|
+
docsUrl: null,
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
name: "ListHeader",
|
|
2354
|
+
description:
|
|
2355
|
+
"Themed column header for a table of `<ListRow>`s. `<ListHeader><Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text><Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text></ListHeader>` above `<List>{rows.map((r) => <ListRow key={r.id}><Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text><Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text></ListRow>)}</List>`. THE way to label a table's columns: it takes the SAME horizontal padding, gap and side edges as `<ListRow>` from the theme's Lists settings, so a label given the same `flex` as its row cell sits exactly over that column — never hand-build a header `View` with its own padding, which cannot line up with the rows. Give the header and the rows the same shape (the same cells as children, each column the same `flex`, `minWidth: 0` on every cell). Props: `children` (the labels), `leading` / `trailing` (a spacer as wide as the rows' leading / trailing node, so the columns still line up), `divider` (default true), `accessibilityLabel`, `style`, `testID`, and the list overrides that shape it (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`) — pass it the SAME overrides you pass the `<List>`. To keep it in view, make it the FIRST child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`, and give it the surface it sits on (`style={{ backgroundColor: theme.colors.surface }}`) so rows do not show through it: inside the scroller it shares the rows' width beside a scrollbar, which a header outside it cannot.",
|
|
2349
2356
|
rnComponent: null,
|
|
2350
2357
|
docsUrl: null,
|
|
2351
2358
|
},
|
|
@@ -2356,6 +2363,41 @@ const PRIMITIVES = [
|
|
|
2356
2363
|
rnComponent: null,
|
|
2357
2364
|
docsUrl: null,
|
|
2358
2365
|
},
|
|
2366
|
+
{
|
|
2367
|
+
name: "Badge",
|
|
2368
|
+
description:
|
|
2369
|
+
'Themed status pill. `<Badge label={row.status} tone={row.status === "Overdue" ? "danger" : "success"} />`. THE way to show a record\'s STATE (Active, Paid, Overdue, New) beside the thing it describes: in a `<ListRow>` trailing slot, next to a title, in a detail header. The label always renders, so the meaning never rides on colour alone. Keep it to one to three words; a name, amount, date or sentence is plain text, not a badge. Its fill and text colour come from the app\'s theme per tone, so never hand-roll a pill from `View` + radius + a hex. Props: `label` (string or number), `tone` ("neutral" | "info" | "success" | "warning" | "danger", default "neutral"), `icon` (optional lucide name before the label), `style`, `testID`. It hugs its label (`alignSelf: "flex-start"`); pass `style={{ alignSelf: "auto" }}` to let the parent place it.',
|
|
2370
|
+
rnComponent: null,
|
|
2371
|
+
docsUrl: null,
|
|
2372
|
+
},
|
|
2373
|
+
{
|
|
2374
|
+
name: "Avatar",
|
|
2375
|
+
description:
|
|
2376
|
+
'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
|
|
2377
|
+
rnComponent: null,
|
|
2378
|
+
docsUrl: null,
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
name: "Progress",
|
|
2382
|
+
description:
|
|
2383
|
+
'Themed completion bar or ring. `<Progress value={(done / total) * 100} label="Sessions booked" />`. THE way to show real progress toward a known goal. `value` is clamped to 0-100, and the percent renders as text unless `showValue={false}`. Never decoration, a rating or a score. Props: `value`, `variant` ("bar" | "ring", default "bar"), `label`, `showValue` (default true), `tone` ("primary" | "success" | "warning" | "danger", default "primary"), `style`, `testID`.',
|
|
2384
|
+
rnComponent: null,
|
|
2385
|
+
docsUrl: null,
|
|
2386
|
+
},
|
|
2387
|
+
{
|
|
2388
|
+
name: "Stat",
|
|
2389
|
+
description:
|
|
2390
|
+
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
|
|
2391
|
+
rnComponent: null,
|
|
2392
|
+
docsUrl: null,
|
|
2393
|
+
},
|
|
2394
|
+
{
|
|
2395
|
+
name: "Divider",
|
|
2396
|
+
description:
|
|
2397
|
+
'Themed rule. `<Divider />`, or `<Divider label="or" />` for a captioned one. It splits two groups INSIDE one surface that nothing else separates: a card\'s summary from its details, or two alternatives. A `<List>` already draws its own row dividers and a `<Card>` its own edge, so never add one between rows or around a card. Props: `label` (optional caption centred in the rule), `thickness` (1-8 px), `spacing` ("sm" | "md" | "lg" breathing room), `color` (defaults to the theme border), `style`, `testID`.',
|
|
2398
|
+
rnComponent: null,
|
|
2399
|
+
docsUrl: null,
|
|
2400
|
+
},
|
|
2359
2401
|
{
|
|
2360
2402
|
name: "Tabs",
|
|
2361
2403
|
description:
|
|
@@ -4613,7 +4655,13 @@ const CONTRACT = deepFreeze({
|
|
|
4613
4655
|
// parameters a Run action event sent (the binding's `params` merged over
|
|
4614
4656
|
// the emitting widget's payload), deep-frozen, null on every non-`app`
|
|
4615
4657
|
// trigger. Additive: a script that never reads it runs unchanged.
|
|
4616
|
-
|
|
4658
|
+
// 1.132.0: additive (sc-9086) — the `Badge`, `Avatar`, `Progress`, `Stat`
|
|
4659
|
+
// and `Divider` primitives. The stock widgets of the same names render
|
|
4660
|
+
// through them, so a custom widget's status pill matches a built-in's.
|
|
4661
|
+
// 1.133.0: additive (sc-9002) — `ListHeader` (column labels on `ListRow`'s frame);
|
|
4662
|
+
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4663
|
+
// no longer shifts its content. Other rows render as before.
|
|
4664
|
+
version: "1.133.0",
|
|
4617
4665
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4618
4666
|
hooks: HOOKS,
|
|
4619
4667
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -2345,7 +2345,14 @@ const PRIMITIVES = [
|
|
|
2345
2345
|
{
|
|
2346
2346
|
name: "ListRow",
|
|
2347
2347
|
description:
|
|
2348
|
-
'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block,
|
|
2348
|
+
'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
|
|
2349
|
+
rnComponent: null,
|
|
2350
|
+
docsUrl: null,
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
name: "ListHeader",
|
|
2354
|
+
description:
|
|
2355
|
+
"Themed column header for a table of `<ListRow>`s. `<ListHeader><Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text><Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text></ListHeader>` above `<List>{rows.map((r) => <ListRow key={r.id}><Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text><Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text></ListRow>)}</List>`. THE way to label a table's columns: it takes the SAME horizontal padding, gap and side edges as `<ListRow>` from the theme's Lists settings, so a label given the same `flex` as its row cell sits exactly over that column — never hand-build a header `View` with its own padding, which cannot line up with the rows. Give the header and the rows the same shape (the same cells as children, each column the same `flex`, `minWidth: 0` on every cell). Props: `children` (the labels), `leading` / `trailing` (a spacer as wide as the rows' leading / trailing node, so the columns still line up), `divider` (default true), `accessibilityLabel`, `style`, `testID`, and the list overrides that shape it (`rowPadding`, `gap`, `dividerColor`, `dividerWidth`) — pass it the SAME overrides you pass the `<List>`. To keep it in view, make it the FIRST child of the rows' `ScrollView` with `stickyHeaderIndices={[0]}`, and give it the surface it sits on (`style={{ backgroundColor: theme.colors.surface }}`) so rows do not show through it: inside the scroller it shares the rows' width beside a scrollbar, which a header outside it cannot.",
|
|
2349
2356
|
rnComponent: null,
|
|
2350
2357
|
docsUrl: null,
|
|
2351
2358
|
},
|
|
@@ -2356,6 +2363,41 @@ const PRIMITIVES = [
|
|
|
2356
2363
|
rnComponent: null,
|
|
2357
2364
|
docsUrl: null,
|
|
2358
2365
|
},
|
|
2366
|
+
{
|
|
2367
|
+
name: "Badge",
|
|
2368
|
+
description:
|
|
2369
|
+
'Themed status pill. `<Badge label={row.status} tone={row.status === "Overdue" ? "danger" : "success"} />`. THE way to show a record\'s STATE (Active, Paid, Overdue, New) beside the thing it describes: in a `<ListRow>` trailing slot, next to a title, in a detail header. The label always renders, so the meaning never rides on colour alone. Keep it to one to three words; a name, amount, date or sentence is plain text, not a badge. Its fill and text colour come from the app\'s theme per tone, so never hand-roll a pill from `View` + radius + a hex. Props: `label` (string or number), `tone` ("neutral" | "info" | "success" | "warning" | "danger", default "neutral"), `icon` (optional lucide name before the label), `style`, `testID`. It hugs its label (`alignSelf: "flex-start"`); pass `style={{ alignSelf: "auto" }}` to let the parent place it.',
|
|
2370
|
+
rnComponent: null,
|
|
2371
|
+
docsUrl: null,
|
|
2372
|
+
},
|
|
2373
|
+
{
|
|
2374
|
+
name: "Avatar",
|
|
2375
|
+
description:
|
|
2376
|
+
'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
|
|
2377
|
+
rnComponent: null,
|
|
2378
|
+
docsUrl: null,
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
name: "Progress",
|
|
2382
|
+
description:
|
|
2383
|
+
'Themed completion bar or ring. `<Progress value={(done / total) * 100} label="Sessions booked" />`. THE way to show real progress toward a known goal. `value` is clamped to 0-100, and the percent renders as text unless `showValue={false}`. Never decoration, a rating or a score. Props: `value`, `variant` ("bar" | "ring", default "bar"), `label`, `showValue` (default true), `tone` ("primary" | "success" | "warning" | "danger", default "primary"), `style`, `testID`.',
|
|
2384
|
+
rnComponent: null,
|
|
2385
|
+
docsUrl: null,
|
|
2386
|
+
},
|
|
2387
|
+
{
|
|
2388
|
+
name: "Stat",
|
|
2389
|
+
description:
|
|
2390
|
+
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
|
|
2391
|
+
rnComponent: null,
|
|
2392
|
+
docsUrl: null,
|
|
2393
|
+
},
|
|
2394
|
+
{
|
|
2395
|
+
name: "Divider",
|
|
2396
|
+
description:
|
|
2397
|
+
'Themed rule. `<Divider />`, or `<Divider label="or" />` for a captioned one. It splits two groups INSIDE one surface that nothing else separates: a card\'s summary from its details, or two alternatives. A `<List>` already draws its own row dividers and a `<Card>` its own edge, so never add one between rows or around a card. Props: `label` (optional caption centred in the rule), `thickness` (1-8 px), `spacing` ("sm" | "md" | "lg" breathing room), `color` (defaults to the theme border), `style`, `testID`.',
|
|
2398
|
+
rnComponent: null,
|
|
2399
|
+
docsUrl: null,
|
|
2400
|
+
},
|
|
2359
2401
|
{
|
|
2360
2402
|
name: "Tabs",
|
|
2361
2403
|
description:
|
|
@@ -4609,7 +4651,13 @@ const CONTRACT = deepFreeze({
|
|
|
4609
4651
|
// parameters a Run action event sent (the binding's `params` merged over
|
|
4610
4652
|
// the emitting widget's payload), deep-frozen, null on every non-`app`
|
|
4611
4653
|
// trigger. Additive: a script that never reads it runs unchanged.
|
|
4612
|
-
|
|
4654
|
+
// 1.132.0: additive (sc-9086) — the `Badge`, `Avatar`, `Progress`, `Stat`
|
|
4655
|
+
// and `Divider` primitives. The stock widgets of the same names render
|
|
4656
|
+
// through them, so a custom widget's status pill matches a built-in's.
|
|
4657
|
+
// 1.133.0: additive (sc-9002) — `ListHeader` (column labels on `ListRow`'s frame);
|
|
4658
|
+
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4659
|
+
// no longer shifts its content. Other rows render as before.
|
|
4660
|
+
version: "1.133.0",
|
|
4613
4661
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4614
4662
|
hooks: HOOKS,
|
|
4615
4663
|
primitives: PRIMITIVES,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The pure rules behind `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>` and
|
|
2
|
+
// `<Divider>`: which theme colour a tone reads, and the clamps that keep a
|
|
3
|
+
// bad prop from reaching a native style as NaN (a crash on the export).
|
|
4
|
+
|
|
5
|
+
import { deriveAccentTints } from "./contract.js";
|
|
6
|
+
|
|
7
|
+
export const BADGE_TONES = Object.freeze(["neutral", "info", "success", "warning", "danger"]);
|
|
8
|
+
export const PROGRESS_TONES = Object.freeze(["primary", "success", "warning", "danger"]);
|
|
9
|
+
export const STAT_DELTA_TONES = Object.freeze(["neutral", "positive", "negative"]);
|
|
10
|
+
export const AVATAR_SIZES = Object.freeze({ sm: 32, md: 40, lg: 56, xl: 80 });
|
|
11
|
+
export const DIVIDER_SPACINGS = Object.freeze(["sm", "md", "lg"]);
|
|
12
|
+
|
|
13
|
+
const AVATAR_PX_BOUNDS = Object.freeze({ min: 16, max: 160 });
|
|
14
|
+
const DIVIDER_THICKNESS_BOUNDS = Object.freeze({ min: 1, max: 8 });
|
|
15
|
+
const STAT_FONT_SIZE_BOUNDS = Object.freeze({ min: 12, max: 96 });
|
|
16
|
+
|
|
17
|
+
const DELTA_TONE_COLORS = Object.freeze({
|
|
18
|
+
neutral: "onSurfaceMuted",
|
|
19
|
+
positive: "success",
|
|
20
|
+
negative: "danger",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function clamp(n, { min, max }) {
|
|
24
|
+
return Math.min(max, Math.max(min, n));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The soft fill + readable text pair for a badge tone, derived from the theme's
|
|
29
|
+
* own colours. `neutral` (or an unknown tone) is the muted surface pair.
|
|
30
|
+
*/
|
|
31
|
+
export function badgeTint(colors, tone) {
|
|
32
|
+
const muted = { backgroundColor: colors.surfaceMuted, color: colors.onSurfaceMuted };
|
|
33
|
+
const accent = tone !== "neutral" && BADGE_TONES.includes(tone) ? colors[tone] : null;
|
|
34
|
+
if (!accent) return muted;
|
|
35
|
+
const tints = deriveAccentTints(accent, colors.surface, colors.onSurface);
|
|
36
|
+
return tints ? { backgroundColor: tints.primarySoft, color: tints.onPrimarySoft } : muted;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A 0-100 percent; anything unreadable is 0. */
|
|
40
|
+
export function clampPercent(value) {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return Number.isFinite(n) ? clamp(n, { min: 0, max: 100 }) : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The fill colour a progress tone reads; an unknown tone is the primary. */
|
|
46
|
+
export function progressColor(colors, tone) {
|
|
47
|
+
return colors[PROGRESS_TONES.includes(tone) ? tone : "primary"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An avatar's edge in px: a size token, or a number clamped to a sane range.
|
|
52
|
+
* Object.hasOwn, because an inherited key ("constructor") is truthy.
|
|
53
|
+
*/
|
|
54
|
+
export function avatarPixelSize(size) {
|
|
55
|
+
if (typeof size === "number" && Number.isFinite(size)) {
|
|
56
|
+
return Math.round(clamp(size, AVATAR_PX_BOUNDS));
|
|
57
|
+
}
|
|
58
|
+
return Object.hasOwn(AVATAR_SIZES, size) ? AVATAR_SIZES[size] : AVATAR_SIZES.md;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A divider's line height in whole px, 1-8. */
|
|
62
|
+
export function dividerThickness(value) {
|
|
63
|
+
const n = Number(value);
|
|
64
|
+
if (!Number.isFinite(n)) return DIVIDER_THICKNESS_BOUNDS.min;
|
|
65
|
+
return clamp(Math.round(n), DIVIDER_THICKNESS_BOUNDS);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The spacing token a divider's breathing room reads; unknown is "md". */
|
|
69
|
+
export function dividerSpacing(spacing) {
|
|
70
|
+
return DIVIDER_SPACINGS.includes(spacing) ? spacing : "md";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A stat's value size: the caller's, when it is in range, else the theme's xxl. */
|
|
74
|
+
export function statFontSize(fontSize, typography) {
|
|
75
|
+
const inRange =
|
|
76
|
+
typeof fontSize === "number" &&
|
|
77
|
+
fontSize >= STAT_FONT_SIZE_BOUNDS.min &&
|
|
78
|
+
fontSize <= STAT_FONT_SIZE_BOUNDS.max;
|
|
79
|
+
return inRange ? fontSize : typography.sizes.xxl;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The colour a stat's delta reads; an unknown tone is neutral. */
|
|
83
|
+
export function statDeltaColor(colors, tone) {
|
|
84
|
+
return colors[DELTA_TONE_COLORS[STAT_DELTA_TONES.includes(tone) ? tone : "neutral"]];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A value worth printing: a finite number or non-blank text. */
|
|
88
|
+
export function hasStatValue(value) {
|
|
89
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
90
|
+
return typeof value === "string" && value.trim() !== "";
|
|
91
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Badge>`, `<Avatar>`, `<Progress>`,
|
|
2
|
+
// `<Stat>` and `<Divider>` SDK components and the stock widgets of the same
|
|
3
|
+
// names. The bindings (`detail-parts.js` / `detail-parts.native.js`) differ
|
|
4
|
+
// only in where the primitives come from, so the hosts cannot drift.
|
|
5
|
+
//
|
|
6
|
+
// Meaning is never carried by colour alone: a badge always prints its label, a
|
|
7
|
+
// progress its percent (unless the caller hides it), a stat's delta its sign.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { lucideGlyph, Icon } from "./icon.js";
|
|
13
|
+
import { initialsFor } from "./initials.js";
|
|
14
|
+
import {
|
|
15
|
+
avatarPixelSize,
|
|
16
|
+
badgeTint,
|
|
17
|
+
clampPercent,
|
|
18
|
+
dividerSpacing,
|
|
19
|
+
dividerThickness,
|
|
20
|
+
hasStatValue,
|
|
21
|
+
progressColor,
|
|
22
|
+
statDeltaColor,
|
|
23
|
+
statFontSize,
|
|
24
|
+
} from "./detail-parts-tokens.js";
|
|
25
|
+
|
|
26
|
+
const RING_SIZE_PX = 72;
|
|
27
|
+
const RING_STROKE_PX = 8;
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
|
|
30
|
+
// Outside a widget host (a test, a preview) the defaults still paint.
|
|
31
|
+
function useDetailTheme() {
|
|
32
|
+
return useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function glyph(name, size, color) {
|
|
36
|
+
const Glyph = lucideGlyph(name);
|
|
37
|
+
return Glyph ? h(Glyph, { size, color }) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A count badge may be a number; an empty one still shows it is a badge.
|
|
41
|
+
function badgeText(label) {
|
|
42
|
+
if (typeof label === "number" && Number.isFinite(label)) return String(label);
|
|
43
|
+
return typeof label === "string" && label !== "" ? label : "Badge";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captionText(value) {
|
|
47
|
+
return typeof value === "string" ? value.trim() : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bind the detail parts to one platform's primitives.
|
|
52
|
+
*
|
|
53
|
+
* @param {{View: any, Text: any, Image: any, Svg: any, Circle: any}} rn
|
|
54
|
+
*/
|
|
55
|
+
export function makeDetailParts(rn) {
|
|
56
|
+
const { View, Text, Image, Svg, Circle } = rn;
|
|
57
|
+
|
|
58
|
+
function Badge({ label, tone = "neutral", icon, style, testID }) {
|
|
59
|
+
const { colors, spacing, radii, typography } = useDetailTheme();
|
|
60
|
+
const tint = badgeTint(colors, tone);
|
|
61
|
+
const text = badgeText(label);
|
|
62
|
+
const pill = {
|
|
63
|
+
// A pill hugs its label instead of stretching across a column.
|
|
64
|
+
alignSelf: "flex-start",
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
gap: spacing.xs,
|
|
68
|
+
borderRadius: radii.pill,
|
|
69
|
+
paddingVertical: spacing.xs,
|
|
70
|
+
paddingHorizontal: spacing.sm,
|
|
71
|
+
backgroundColor: tint.backgroundColor,
|
|
72
|
+
};
|
|
73
|
+
const labelStyle = {
|
|
74
|
+
color: tint.color,
|
|
75
|
+
fontSize: typography.sizes.xs,
|
|
76
|
+
fontWeight: "600",
|
|
77
|
+
fontFamily: typography.fontFamily,
|
|
78
|
+
};
|
|
79
|
+
return h(
|
|
80
|
+
View,
|
|
81
|
+
{ style: [pill, style], testID },
|
|
82
|
+
glyph(icon, typography.sizes.xs, tint.color),
|
|
83
|
+
h(Text, { style: labelStyle }, text),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
Badge.displayName = "Badge";
|
|
87
|
+
|
|
88
|
+
function Avatar({ name, url, size = "md", shape = "circle", accessibilityLabel, style, testID }) {
|
|
89
|
+
const { colors, radii, typography } = useDetailTheme();
|
|
90
|
+
const px = avatarPixelSize(size);
|
|
91
|
+
const box = {
|
|
92
|
+
alignSelf: "flex-start",
|
|
93
|
+
width: px,
|
|
94
|
+
height: px,
|
|
95
|
+
borderRadius: shape === "rounded" ? radii.md : px / 2,
|
|
96
|
+
backgroundColor: colors.surfaceMuted,
|
|
97
|
+
};
|
|
98
|
+
const source = typeof url === "string" ? url.trim() : "";
|
|
99
|
+
const label = accessibilityLabel || captionText(name) || "Avatar";
|
|
100
|
+
if (source) {
|
|
101
|
+
return h(Image, {
|
|
102
|
+
source: { uri: source },
|
|
103
|
+
accessibilityLabel: label,
|
|
104
|
+
resizeMode: "cover",
|
|
105
|
+
style: [box, style],
|
|
106
|
+
testID,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const initials = initialsFor(name);
|
|
110
|
+
const fallback = { alignItems: "center", justifyContent: "center" };
|
|
111
|
+
const content = initials
|
|
112
|
+
? h(
|
|
113
|
+
Text,
|
|
114
|
+
{
|
|
115
|
+
style: {
|
|
116
|
+
color: colors.onSurfaceMuted,
|
|
117
|
+
fontWeight: "600",
|
|
118
|
+
fontFamily: typography.fontFamily,
|
|
119
|
+
fontSize: Math.round(px * 0.4),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
initials,
|
|
123
|
+
)
|
|
124
|
+
: h(Icon, { name: "user", size: Math.round(px * 0.55), color: colors.onSurfaceMuted });
|
|
125
|
+
return h(View, { style: [box, fallback, style], accessibilityLabel: label, testID }, content);
|
|
126
|
+
}
|
|
127
|
+
Avatar.displayName = "Avatar";
|
|
128
|
+
|
|
129
|
+
function ProgressRing({ percent, fill, showValue, caption, theme }) {
|
|
130
|
+
const { colors, spacing, typography } = theme;
|
|
131
|
+
const radius = (RING_SIZE_PX - RING_STROKE_PX) / 2;
|
|
132
|
+
const circumference = 2 * Math.PI * radius;
|
|
133
|
+
const centre = RING_SIZE_PX / 2;
|
|
134
|
+
const ring = { cx: centre, cy: centre, r: radius, strokeWidth: RING_STROKE_PX, fill: "none" };
|
|
135
|
+
return h(
|
|
136
|
+
View,
|
|
137
|
+
{ style: { alignItems: "center" } },
|
|
138
|
+
h(
|
|
139
|
+
View,
|
|
140
|
+
{ style: { width: RING_SIZE_PX, height: RING_SIZE_PX } },
|
|
141
|
+
h(
|
|
142
|
+
Svg,
|
|
143
|
+
{ width: RING_SIZE_PX, height: RING_SIZE_PX },
|
|
144
|
+
h(Circle, { ...ring, stroke: colors.surfaceMuted }),
|
|
145
|
+
h(Circle, {
|
|
146
|
+
...ring,
|
|
147
|
+
stroke: fill,
|
|
148
|
+
strokeLinecap: "round",
|
|
149
|
+
strokeDasharray: `${circumference} ${circumference}`,
|
|
150
|
+
strokeDashoffset: circumference * (1 - percent / 100),
|
|
151
|
+
rotation: -90,
|
|
152
|
+
originX: centre,
|
|
153
|
+
originY: centre,
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
showValue
|
|
157
|
+
? h(
|
|
158
|
+
View,
|
|
159
|
+
{
|
|
160
|
+
style: {
|
|
161
|
+
position: "absolute",
|
|
162
|
+
top: 0,
|
|
163
|
+
left: 0,
|
|
164
|
+
right: 0,
|
|
165
|
+
bottom: 0,
|
|
166
|
+
alignItems: "center",
|
|
167
|
+
justifyContent: "center",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
h(
|
|
171
|
+
Text,
|
|
172
|
+
{
|
|
173
|
+
style: {
|
|
174
|
+
fontSize: typography.sizes.md,
|
|
175
|
+
fontWeight: "700",
|
|
176
|
+
color: colors.onSurface,
|
|
177
|
+
fontFamily: typography.fontFamily,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
`${Math.round(percent)}%`,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
: null,
|
|
184
|
+
),
|
|
185
|
+
caption
|
|
186
|
+
? h(
|
|
187
|
+
Text,
|
|
188
|
+
{
|
|
189
|
+
style: {
|
|
190
|
+
marginTop: spacing.xs,
|
|
191
|
+
fontSize: typography.sizes.sm,
|
|
192
|
+
color: colors.onSurfaceMuted,
|
|
193
|
+
fontFamily: typography.fontFamily,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
caption,
|
|
197
|
+
)
|
|
198
|
+
: null,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ProgressBar({ percent, fill, showValue, caption, theme }) {
|
|
203
|
+
const { colors, spacing, radii, typography } = theme;
|
|
204
|
+
const small = { fontSize: typography.sizes.sm, fontFamily: typography.fontFamily };
|
|
205
|
+
const header =
|
|
206
|
+
caption || showValue
|
|
207
|
+
? h(
|
|
208
|
+
View,
|
|
209
|
+
{
|
|
210
|
+
style: {
|
|
211
|
+
flexDirection: "row",
|
|
212
|
+
alignItems: "center",
|
|
213
|
+
justifyContent: "flex-end",
|
|
214
|
+
marginBottom: spacing.xs,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
caption
|
|
218
|
+
? h(Text, { style: { ...small, flex: 1, color: colors.onSurfaceMuted } }, caption)
|
|
219
|
+
: null,
|
|
220
|
+
showValue
|
|
221
|
+
? h(
|
|
222
|
+
Text,
|
|
223
|
+
{
|
|
224
|
+
style: {
|
|
225
|
+
...small,
|
|
226
|
+
marginLeft: spacing.xs,
|
|
227
|
+
fontWeight: "600",
|
|
228
|
+
color: colors.onSurface,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
`${Math.round(percent)}%`,
|
|
232
|
+
)
|
|
233
|
+
: null,
|
|
234
|
+
)
|
|
235
|
+
: null;
|
|
236
|
+
const track = {
|
|
237
|
+
width: "100%",
|
|
238
|
+
height: spacing.sm,
|
|
239
|
+
borderRadius: radii.pill,
|
|
240
|
+
backgroundColor: colors.surfaceMuted,
|
|
241
|
+
overflow: "hidden",
|
|
242
|
+
};
|
|
243
|
+
return h(
|
|
244
|
+
View,
|
|
245
|
+
{ style: { width: "100%" } },
|
|
246
|
+
header,
|
|
247
|
+
h(
|
|
248
|
+
View,
|
|
249
|
+
{ style: track },
|
|
250
|
+
h(View, {
|
|
251
|
+
style: {
|
|
252
|
+
height: "100%",
|
|
253
|
+
borderRadius: radii.pill,
|
|
254
|
+
width: `${percent}%`,
|
|
255
|
+
backgroundColor: fill,
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function Progress({ value = 0, variant = "bar", label, showValue = true, tone = "primary", style, testID }) {
|
|
263
|
+
const theme = useDetailTheme();
|
|
264
|
+
const percent = clampPercent(value);
|
|
265
|
+
const parts = {
|
|
266
|
+
percent,
|
|
267
|
+
fill: progressColor(theme.colors, tone),
|
|
268
|
+
showValue: showValue !== false,
|
|
269
|
+
caption: captionText(label),
|
|
270
|
+
theme,
|
|
271
|
+
};
|
|
272
|
+
return h(
|
|
273
|
+
View,
|
|
274
|
+
{
|
|
275
|
+
style: [{ width: "100%" }, style],
|
|
276
|
+
// The aria props, not accessibilityValue: react-native-web drops that one.
|
|
277
|
+
role: "progressbar",
|
|
278
|
+
"aria-valuemin": 0,
|
|
279
|
+
"aria-valuemax": 100,
|
|
280
|
+
"aria-valuenow": Math.round(percent),
|
|
281
|
+
testID,
|
|
282
|
+
},
|
|
283
|
+
variant === "ring" ? h(ProgressRing, parts) : h(ProgressBar, parts),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
Progress.displayName = "Progress";
|
|
287
|
+
|
|
288
|
+
function Stat({
|
|
289
|
+
value,
|
|
290
|
+
label,
|
|
291
|
+
icon,
|
|
292
|
+
delta,
|
|
293
|
+
deltaTone = "neutral",
|
|
294
|
+
emptyValue = "—",
|
|
295
|
+
color,
|
|
296
|
+
fontSize,
|
|
297
|
+
style,
|
|
298
|
+
testID,
|
|
299
|
+
}) {
|
|
300
|
+
const { colors, spacing, typography } = useDetailTheme();
|
|
301
|
+
const empty = !hasStatValue(value);
|
|
302
|
+
const shown = empty ? (typeof emptyValue === "string" ? emptyValue : "") : String(value);
|
|
303
|
+
const size = statFontSize(fontSize, typography);
|
|
304
|
+
const valueColor = empty ? colors.onSurfaceMuted : color || colors.onSurface;
|
|
305
|
+
const deltaText = captionText(delta);
|
|
306
|
+
const caption = typeof label === "string" ? label : "";
|
|
307
|
+
return h(
|
|
308
|
+
View,
|
|
309
|
+
{ style: [{ alignItems: "flex-start" }, style], testID },
|
|
310
|
+
h(
|
|
311
|
+
View,
|
|
312
|
+
{ style: { flexDirection: "row", alignItems: "center", gap: spacing.xs } },
|
|
313
|
+
glyph(icon, Math.max(12, Math.round(size * 0.75)), valueColor),
|
|
314
|
+
h(
|
|
315
|
+
Text,
|
|
316
|
+
{
|
|
317
|
+
style: {
|
|
318
|
+
fontWeight: "700",
|
|
319
|
+
fontFamily: typography.headingFontFamily,
|
|
320
|
+
fontSize: size,
|
|
321
|
+
color: valueColor,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
shown,
|
|
325
|
+
),
|
|
326
|
+
deltaText
|
|
327
|
+
? h(
|
|
328
|
+
Text,
|
|
329
|
+
{
|
|
330
|
+
style: {
|
|
331
|
+
fontSize: typography.sizes.sm,
|
|
332
|
+
fontWeight: "600",
|
|
333
|
+
fontFamily: typography.fontFamily,
|
|
334
|
+
color: statDeltaColor(colors, deltaTone),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
deltaText,
|
|
338
|
+
)
|
|
339
|
+
: null,
|
|
340
|
+
),
|
|
341
|
+
caption
|
|
342
|
+
? h(
|
|
343
|
+
Text,
|
|
344
|
+
{
|
|
345
|
+
style: {
|
|
346
|
+
marginTop: spacing.xs,
|
|
347
|
+
fontSize: typography.sizes.sm,
|
|
348
|
+
color: colors.onSurfaceMuted,
|
|
349
|
+
fontFamily: typography.fontFamily,
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
caption,
|
|
353
|
+
)
|
|
354
|
+
: null,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
Stat.displayName = "Stat";
|
|
358
|
+
|
|
359
|
+
function Divider({ label, thickness = 1, spacing = "md", color, style, testID }) {
|
|
360
|
+
const theme = useDetailTheme();
|
|
361
|
+
const caption = captionText(label);
|
|
362
|
+
const rule = {
|
|
363
|
+
height: dividerThickness(thickness),
|
|
364
|
+
backgroundColor: color || theme.colors.border,
|
|
365
|
+
};
|
|
366
|
+
const root = { width: "100%", paddingVertical: theme.spacing[dividerSpacing(spacing)] };
|
|
367
|
+
const body = caption
|
|
368
|
+
? h(
|
|
369
|
+
View,
|
|
370
|
+
{ style: { flexDirection: "row", alignItems: "center" } },
|
|
371
|
+
h(View, { style: [{ flex: 1 }, rule] }),
|
|
372
|
+
h(
|
|
373
|
+
Text,
|
|
374
|
+
{
|
|
375
|
+
style: {
|
|
376
|
+
marginHorizontal: theme.spacing.sm,
|
|
377
|
+
fontSize: theme.typography.sizes.sm,
|
|
378
|
+
color: theme.colors.onSurfaceMuted,
|
|
379
|
+
fontFamily: theme.typography.fontFamily,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
caption,
|
|
383
|
+
),
|
|
384
|
+
h(View, { style: [{ flex: 1 }, rule] }),
|
|
385
|
+
)
|
|
386
|
+
: h(View, { style: [{ width: "100%" }, rule] });
|
|
387
|
+
return h(View, { style: [root, style], testID }, body);
|
|
388
|
+
}
|
|
389
|
+
Divider.displayName = "Divider";
|
|
390
|
+
|
|
391
|
+
return { Badge, Avatar, Progress, Stat, Divider };
|
|
392
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>`, `<Divider>` (web). Binds
|
|
2
|
+
// ./detail-parts-view.js to react-native-web; see ./primitives.js for why not
|
|
3
|
+
// the bare specifier.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import Svg, { Circle } from "react-native-svg";
|
|
7
|
+
import { makeDetailParts } from "./detail-parts-view.js";
|
|
8
|
+
|
|
9
|
+
export const { Badge, Avatar, Progress, Stat, Divider } = makeDetailParts({
|
|
10
|
+
View: ReactNative.View,
|
|
11
|
+
Text: ReactNative.Text,
|
|
12
|
+
Image: ReactNative.Image,
|
|
13
|
+
Svg,
|
|
14
|
+
Circle,
|
|
15
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>`, `<Divider>` (native). Binds
|
|
2
|
+
// ./detail-parts-view.js to React Native; only the imports differ from
|
|
3
|
+
// ./detail-parts.js.
|
|
4
|
+
|
|
5
|
+
import { View, Text, Image } from "react-native";
|
|
6
|
+
import Svg, { Circle } from "react-native-svg";
|
|
7
|
+
import { makeDetailParts } from "./detail-parts-view.js";
|
|
8
|
+
|
|
9
|
+
export const { Badge, Avatar, Progress, Stat, Divider } = makeDetailParts({
|
|
10
|
+
View,
|
|
11
|
+
Text,
|
|
12
|
+
Image,
|
|
13
|
+
Svg,
|
|
14
|
+
Circle,
|
|
15
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -2601,7 +2601,48 @@ export const ListRow: (props: ListRowTokens & {
|
|
|
2601
2601
|
accessibilityLabel?: string;
|
|
2602
2602
|
style?: any;
|
|
2603
2603
|
testID?: string;
|
|
2604
|
-
/**
|
|
2604
|
+
/**
|
|
2605
|
+
* Replaces the title block, e.g. for a multi-column row. A single `View`
|
|
2606
|
+
* child spans the row between `leading` and `trailing`; anything else
|
|
2607
|
+
* (several cells, say) sits in the row as given.
|
|
2608
|
+
*/
|
|
2609
|
+
children?: ReactNode;
|
|
2610
|
+
}) => any;
|
|
2611
|
+
|
|
2612
|
+
/**
|
|
2613
|
+
* A table's column labels. Shares `<ListRow>`'s horizontal padding, gap and
|
|
2614
|
+
* side edges (from the same Theme Settings > Lists values), so a label given
|
|
2615
|
+
* the same flex as its row cell sits exactly over that column. Pass it the
|
|
2616
|
+
* same overrides as the `<List>`. Make it the rows' `ScrollView`'s first,
|
|
2617
|
+
* sticky child so it shares their width beside a scrollbar.
|
|
2618
|
+
*
|
|
2619
|
+
* @example
|
|
2620
|
+
* <ScrollView stickyHeaderIndices={[0]}>
|
|
2621
|
+
* <ListHeader style={{ backgroundColor: theme.colors.surface }}>
|
|
2622
|
+
* <Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text>
|
|
2623
|
+
* <Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text>
|
|
2624
|
+
* </ListHeader>
|
|
2625
|
+
* <List>
|
|
2626
|
+
* {rows.map((r) => (
|
|
2627
|
+
* <ListRow key={r.id} onPress={() => open(r)}>
|
|
2628
|
+
* <Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text>
|
|
2629
|
+
* <Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text>
|
|
2630
|
+
* </ListRow>
|
|
2631
|
+
* ))}
|
|
2632
|
+
* </List>
|
|
2633
|
+
* </ScrollView>
|
|
2634
|
+
*/
|
|
2635
|
+
export const ListHeader: (props: Pick<ListRowTokens, "rowPadding" | "gap" | "dividerColor" | "dividerWidth"> & {
|
|
2636
|
+
/** A spacer as wide as the rows' `leading`, so the columns still line up. */
|
|
2637
|
+
leading?: ReactNode;
|
|
2638
|
+
/** A spacer (or label) as wide as the rows' `trailing`. */
|
|
2639
|
+
trailing?: ReactNode;
|
|
2640
|
+
/** The divider under the labels. Defaults to true. */
|
|
2641
|
+
divider?: boolean;
|
|
2642
|
+
accessibilityLabel?: string;
|
|
2643
|
+
style?: any;
|
|
2644
|
+
testID?: string;
|
|
2645
|
+
/** The column labels, shaped exactly like the rows' `children`. */
|
|
2605
2646
|
children?: ReactNode;
|
|
2606
2647
|
}) => any;
|
|
2607
2648
|
|
|
@@ -2616,6 +2657,82 @@ export const EmptyState: (props: {
|
|
|
2616
2657
|
children?: ReactNode;
|
|
2617
2658
|
}) => any;
|
|
2618
2659
|
|
|
2660
|
+
/**
|
|
2661
|
+
* Themed status pill: a record's state (Active, Paid, Overdue) beside the
|
|
2662
|
+
* thing it describes. The label always renders; the tone only tints it.
|
|
2663
|
+
*
|
|
2664
|
+
* @example
|
|
2665
|
+
* <Badge label={row.status} tone="success" />
|
|
2666
|
+
*/
|
|
2667
|
+
export const Badge: (props: {
|
|
2668
|
+
label?: string | number;
|
|
2669
|
+
tone?: "neutral" | "info" | "success" | "warning" | "danger";
|
|
2670
|
+
/** A lucide icon name shown before the label. */
|
|
2671
|
+
icon?: string;
|
|
2672
|
+
style?: any;
|
|
2673
|
+
testID?: string;
|
|
2674
|
+
}) => any;
|
|
2675
|
+
|
|
2676
|
+
/** Themed picture of a person, falling back to initials, then a glyph. */
|
|
2677
|
+
export const Avatar: (props: {
|
|
2678
|
+
/** Drives the initials and the accessibility label. */
|
|
2679
|
+
name?: string;
|
|
2680
|
+
/** An absolute image URL. */
|
|
2681
|
+
url?: string;
|
|
2682
|
+
/** A size token, or a number of px (clamped to 16-160). */
|
|
2683
|
+
size?: "sm" | "md" | "lg" | "xl" | number;
|
|
2684
|
+
shape?: "circle" | "rounded";
|
|
2685
|
+
accessibilityLabel?: string;
|
|
2686
|
+
style?: any;
|
|
2687
|
+
testID?: string;
|
|
2688
|
+
}) => any;
|
|
2689
|
+
|
|
2690
|
+
/** Themed completion bar or ring for real progress toward a known goal. */
|
|
2691
|
+
export const Progress: (props: {
|
|
2692
|
+
/** Percent complete; clamped to 0-100. */
|
|
2693
|
+
value?: number;
|
|
2694
|
+
variant?: "bar" | "ring";
|
|
2695
|
+
label?: string;
|
|
2696
|
+
/** Render the percent as text. Default true. */
|
|
2697
|
+
showValue?: boolean;
|
|
2698
|
+
tone?: "primary" | "success" | "warning" | "danger";
|
|
2699
|
+
style?: any;
|
|
2700
|
+
testID?: string;
|
|
2701
|
+
}) => any;
|
|
2702
|
+
|
|
2703
|
+
/** Themed headline figure. Displays a value you computed; it fetches nothing. */
|
|
2704
|
+
export const Stat: (props: {
|
|
2705
|
+
/** The figure. Empty (null, blank, NaN) prints `emptyValue` instead. */
|
|
2706
|
+
value?: string | number | null;
|
|
2707
|
+
label?: string;
|
|
2708
|
+
/** A lucide icon name beside the figure. */
|
|
2709
|
+
icon?: string;
|
|
2710
|
+
delta?: string;
|
|
2711
|
+
deltaTone?: "neutral" | "positive" | "negative";
|
|
2712
|
+
/** Printed when `value` is empty. Default "—". */
|
|
2713
|
+
emptyValue?: string;
|
|
2714
|
+
color?: string;
|
|
2715
|
+
/** The figure's size in px (12-96); defaults to the theme's xxl. */
|
|
2716
|
+
fontSize?: number;
|
|
2717
|
+
style?: any;
|
|
2718
|
+
testID?: string;
|
|
2719
|
+
}) => any;
|
|
2720
|
+
|
|
2721
|
+
/** Themed rule splitting two groups inside one surface; optionally captioned. */
|
|
2722
|
+
export const Divider: (props: {
|
|
2723
|
+
label?: string;
|
|
2724
|
+
/** Line thickness in px, 1-8. */
|
|
2725
|
+
thickness?: number;
|
|
2726
|
+
spacing?: "sm" | "md" | "lg";
|
|
2727
|
+
/** Defaults to the theme's border colour. */
|
|
2728
|
+
color?: string;
|
|
2729
|
+
style?: any;
|
|
2730
|
+
testID?: string;
|
|
2731
|
+
}) => any;
|
|
2732
|
+
|
|
2733
|
+
/** A person's initials: "Ada Lovelace" → "AL"; "" when the name has no letters. */
|
|
2734
|
+
export function initialsFor(name: unknown): string;
|
|
2735
|
+
|
|
2619
2736
|
/**
|
|
2620
2737
|
* Themed card surface. With no props it takes the app's card look (Theme
|
|
2621
2738
|
* Settings > Cards). Props override one card; `style` takes the `card*` style
|
package/dist/index.js
CHANGED
|
@@ -196,7 +196,10 @@ export {
|
|
|
196
196
|
} from "./emoji-reactions.js";
|
|
197
197
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
198
198
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
199
|
-
export { List, ListRow, EmptyState } from "./list.js";
|
|
199
|
+
export { List, ListRow, ListHeader, EmptyState } from "./list.js";
|
|
200
|
+
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
201
|
+
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.js";
|
|
202
|
+
export { initialsFor } from "./initials.js";
|
|
200
203
|
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
201
204
|
export { Card } from "./card.js";
|
|
202
205
|
export {
|
package/dist/index.native.js
CHANGED
|
@@ -189,7 +189,10 @@ export {
|
|
|
189
189
|
} from "./emoji-reactions.js";
|
|
190
190
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
191
191
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
192
|
-
export { List, ListRow, EmptyState } from "./list.native.js";
|
|
192
|
+
export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
|
|
193
|
+
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
194
|
+
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
|
|
195
|
+
export { initialsFor } from "./initials.js";
|
|
193
196
|
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
194
197
|
export { Card } from "./card.native.js";
|
|
195
198
|
export {
|
package/dist/initials.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The ONE initials rule, for `<Avatar>` and every built-in that falls back to a
|
|
2
|
+
// person's initials. Two rules would print different letters for one person.
|
|
3
|
+
//
|
|
4
|
+
// Returns "" when the name yields no letters, so each caller keeps its own
|
|
5
|
+
// fallback (a "?", a person glyph).
|
|
6
|
+
|
|
7
|
+
/** "Ada Lovelace" → "AL", "Ada" → "A", " " → "". */
|
|
8
|
+
export function initialsFor(name) {
|
|
9
|
+
const parts =
|
|
10
|
+
typeof name === "string" ? name.trim().split(/\s+/).filter(Boolean) : [];
|
|
11
|
+
if (parts.length === 0) return "";
|
|
12
|
+
// Array.from, not slice: a multi-byte first character must not be halved.
|
|
13
|
+
const first = Array.from(parts[0])[0];
|
|
14
|
+
if (parts.length === 1) return first.toUpperCase();
|
|
15
|
+
const last = Array.from(parts[parts.length - 1])[0];
|
|
16
|
+
return (first + last).toUpperCase();
|
|
17
|
+
}
|
package/dist/list-tokens.js
CHANGED
|
@@ -9,6 +9,9 @@ import { normaliseThemeComponents } from "./theme-components.js";
|
|
|
9
9
|
// A row with no themed height still meets the 44px touch-target floor.
|
|
10
10
|
export const LIST_ROW_MIN_HEIGHT = 44;
|
|
11
11
|
|
|
12
|
+
// The accent bar a selected row carries on its leading edge.
|
|
13
|
+
export const LIST_SELECTED_BAR_WIDTH = 3;
|
|
14
|
+
|
|
12
15
|
// The per-row overrides a widget may pass, named as the theme tokens are.
|
|
13
16
|
export const LIST_TOKEN_NAMES = Object.freeze([
|
|
14
17
|
"rowPadding",
|
|
@@ -66,3 +69,31 @@ export function resolveListTokens(theme, overrides) {
|
|
|
66
69
|
pressedBackground: mixHex(colors.onSurface, background || colors.surface, 0.94),
|
|
67
70
|
};
|
|
68
71
|
}
|
|
72
|
+
|
|
73
|
+
/** The width of a row's side edges: a tile's outline, none on a flush band. */
|
|
74
|
+
export function listSideEdgeWidth(tokens) {
|
|
75
|
+
return tokens.separated ? tokens.dividerWidth : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The horizontal geometry `<ListRow>` and `<ListHeader>` share: the direction,
|
|
80
|
+
* the gap between leading, body and trailing, and the inset. Both draw from
|
|
81
|
+
* this one function, so a header cell sits over its row cell by construction.
|
|
82
|
+
*
|
|
83
|
+
* @param {ReturnType<typeof resolveListTokens>} tokens
|
|
84
|
+
* @param {{ md: number }} spacing — the theme's spacing scale.
|
|
85
|
+
* @param {boolean} [selected] — the row carries the accent bar.
|
|
86
|
+
*/
|
|
87
|
+
export function listRowFrame(tokens, spacing, selected = false) {
|
|
88
|
+
const frame = {
|
|
89
|
+
flexDirection: "row",
|
|
90
|
+
alignItems: "center",
|
|
91
|
+
gap: spacing.md,
|
|
92
|
+
paddingHorizontal: tokens.rowPadding,
|
|
93
|
+
};
|
|
94
|
+
if (!selected) return frame;
|
|
95
|
+
// The bar replaces the side edge and takes its width out of the inset,
|
|
96
|
+
// so a row's cells do not move when it is selected.
|
|
97
|
+
const inset = tokens.rowPadding + listSideEdgeWidth(tokens) - LIST_SELECTED_BAR_WIDTH;
|
|
98
|
+
return { ...frame, paddingLeft: Math.max(0, inset) };
|
|
99
|
+
}
|
package/dist/list-view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The ONE implementation behind the `<List>`, `<ListRow>` and
|
|
2
|
-
// SDK primitives. The platform bindings (`list.js` / `list.native.js`) differ
|
|
1
|
+
// The ONE implementation behind the `<List>`, `<ListRow>`, `<ListHeader>` and
|
|
2
|
+
// `<EmptyState>` SDK primitives. The platform bindings (`list.js` / `list.native.js`) differ
|
|
3
3
|
// only in where the primitives come from, so the hosts cannot drift.
|
|
4
4
|
//
|
|
5
5
|
// Every widget drawing a list of records used to hand-roll its rows, so two
|
|
@@ -10,7 +10,13 @@ import React from "react";
|
|
|
10
10
|
import { useHostTheme } from "./hooks.js";
|
|
11
11
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
12
|
import { Icon } from "./icon.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
resolveListTokens,
|
|
15
|
+
listRowFrame,
|
|
16
|
+
listSideEdgeWidth,
|
|
17
|
+
LIST_TOKEN_NAMES,
|
|
18
|
+
LIST_SELECTED_BAR_WIDTH,
|
|
19
|
+
} from "./list-tokens.js";
|
|
14
20
|
import { textRoleStyle } from "./text-roles.js";
|
|
15
21
|
|
|
16
22
|
// Outside a widget host (a test, a preview) the defaults still paint a row.
|
|
@@ -26,15 +32,49 @@ function listOverrides(props) {
|
|
|
26
32
|
return out;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
function soleElement(children) {
|
|
36
|
+
const parts = React.Children.toArray(children);
|
|
37
|
+
return parts.length === 1 && React.isValidElement(parts[0]) ? parts[0] : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HORIZONTAL_PADDING = [
|
|
41
|
+
"padding",
|
|
42
|
+
"paddingHorizontal",
|
|
43
|
+
"paddingLeft",
|
|
44
|
+
"paddingStart",
|
|
45
|
+
"paddingInline",
|
|
46
|
+
"paddingInlineStart",
|
|
47
|
+
];
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* Bind the shared list components to one platform's primitives.
|
|
31
51
|
*
|
|
32
|
-
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
52
|
+
* @param {{View: any, Text: any, Pressable: any, StyleSheet: any}} rn
|
|
33
53
|
*/
|
|
34
54
|
export function makeList(rn) {
|
|
35
|
-
const { View, Text, Pressable } = rn;
|
|
55
|
+
const { View, Text, Pressable, StyleSheet } = rn;
|
|
36
56
|
const ListContext = React.createContext(null);
|
|
37
57
|
|
|
58
|
+
function useRowTokens(props) {
|
|
59
|
+
const theme = useListTheme();
|
|
60
|
+
const inherited = React.useContext(ListContext);
|
|
61
|
+
return [theme, resolveListTokens(theme, { ...inherited, ...listOverrides(props) })];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A lone row View spans the row, as the title block does. Anything
|
|
65
|
+
// else (cells, a component rendering cells) stays in the row as before.
|
|
66
|
+
function customBody(children) {
|
|
67
|
+
const only = soleElement(children);
|
|
68
|
+
return only && only.type === View
|
|
69
|
+
? React.createElement(View, { style: { flex: 1, minWidth: 0 } }, only)
|
|
70
|
+
: children;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setsOwnInset(style) {
|
|
74
|
+
const flat = StyleSheet.flatten(style) || {};
|
|
75
|
+
return HORIZONTAL_PADDING.some((key) => flat[key] !== undefined);
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
function List(props) {
|
|
39
79
|
const { children, style, testID } = props;
|
|
40
80
|
const theme = useListTheme();
|
|
@@ -72,12 +112,7 @@ export function makeList(rn) {
|
|
|
72
112
|
testID,
|
|
73
113
|
children,
|
|
74
114
|
} = props;
|
|
75
|
-
const theme =
|
|
76
|
-
const inherited = React.useContext(ListContext);
|
|
77
|
-
const tokens = resolveListTokens(theme, {
|
|
78
|
-
...inherited,
|
|
79
|
-
...listOverrides(props),
|
|
80
|
-
});
|
|
115
|
+
const [theme, tokens] = useRowTokens(props);
|
|
81
116
|
const { colors, spacing, typography } = theme;
|
|
82
117
|
|
|
83
118
|
const pressable = typeof onPress === "function";
|
|
@@ -90,10 +125,8 @@ export function makeList(rn) {
|
|
|
90
125
|
? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
91
126
|
: null;
|
|
92
127
|
const rowStyle = {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
gap: spacing.md,
|
|
96
|
-
paddingHorizontal: tokens.rowPadding,
|
|
128
|
+
// An inset the widget sets itself is left exactly as it asked.
|
|
129
|
+
...listRowFrame(tokens, spacing, selected && !setsOwnInset(style)),
|
|
97
130
|
paddingVertical: tokens.rowPadding,
|
|
98
131
|
// A dense look may shrink a static row, never a tap target.
|
|
99
132
|
minHeight: pressable ? Math.max(tokens.rowMinHeight, 44) : tokens.rowMinHeight,
|
|
@@ -104,7 +137,7 @@ export function makeList(rn) {
|
|
|
104
137
|
...(selected
|
|
105
138
|
? {
|
|
106
139
|
backgroundColor: tokens.selectedBackground,
|
|
107
|
-
borderLeftWidth:
|
|
140
|
+
borderLeftWidth: LIST_SELECTED_BAR_WIDTH,
|
|
108
141
|
borderLeftColor: tokens.selectedAccent,
|
|
109
142
|
}
|
|
110
143
|
: null),
|
|
@@ -112,7 +145,7 @@ export function makeList(rn) {
|
|
|
112
145
|
|
|
113
146
|
const body =
|
|
114
147
|
children !== undefined
|
|
115
|
-
? children
|
|
148
|
+
? customBody(children)
|
|
116
149
|
: React.createElement(
|
|
117
150
|
View,
|
|
118
151
|
{ style: { flex: 1, gap: 2 } },
|
|
@@ -174,6 +207,39 @@ export function makeList(rn) {
|
|
|
174
207
|
}
|
|
175
208
|
ListRow.displayName = "ListRow";
|
|
176
209
|
|
|
210
|
+
// A table's column labels: a ListRow's frame and body slot with none of its
|
|
211
|
+
// row chrome, so each label sits over its column.
|
|
212
|
+
function ListHeader(props) {
|
|
213
|
+
const { leading, trailing, divider = true, accessibilityLabel, style, testID, children } = props;
|
|
214
|
+
const [theme, tokens] = useRowTokens(props);
|
|
215
|
+
const { spacing } = theme;
|
|
216
|
+
// Transparent side edges stand in for a tile's outline.
|
|
217
|
+
const side = listSideEdgeWidth(tokens);
|
|
218
|
+
const headerStyle = {
|
|
219
|
+
...listRowFrame(tokens, spacing),
|
|
220
|
+
paddingVertical: Math.min(tokens.rowPadding, spacing.sm),
|
|
221
|
+
...(side > 0
|
|
222
|
+
? { borderLeftWidth: side, borderRightWidth: side, borderColor: "transparent" }
|
|
223
|
+
: null),
|
|
224
|
+
...(divider && tokens.dividerWidth > 0
|
|
225
|
+
? { borderBottomWidth: tokens.dividerWidth, borderBottomColor: tokens.dividerColor }
|
|
226
|
+
: null),
|
|
227
|
+
};
|
|
228
|
+
return React.createElement(
|
|
229
|
+
View,
|
|
230
|
+
{
|
|
231
|
+
style: [headerStyle, style],
|
|
232
|
+
testID,
|
|
233
|
+
accessibilityLabel,
|
|
234
|
+
accessible: accessibilityLabel ? true : undefined,
|
|
235
|
+
},
|
|
236
|
+
leading || null,
|
|
237
|
+
customBody(children),
|
|
238
|
+
trailing || null,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
ListHeader.displayName = "ListHeader";
|
|
242
|
+
|
|
177
243
|
function EmptyState({ icon = "inbox", title, message, children, style, testID }) {
|
|
178
244
|
const { colors, spacing, radii, typography } = useListTheme();
|
|
179
245
|
return React.createElement(
|
|
@@ -211,5 +277,5 @@ export function makeList(rn) {
|
|
|
211
277
|
}
|
|
212
278
|
EmptyState.displayName = "EmptyState";
|
|
213
279
|
|
|
214
|
-
return { List, ListRow, EmptyState };
|
|
280
|
+
return { List, ListRow, ListHeader, EmptyState };
|
|
215
281
|
}
|
package/dist/list.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// `<List>`, `<ListRow>`, `<EmptyState>` (web). Binds
|
|
2
|
-
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
1
|
+
// `<List>`, `<ListRow>`, `<ListHeader>`, `<EmptyState>` (web). Binds
|
|
2
|
+
// ./list-view.js to react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
3
|
|
|
4
4
|
import * as ReactNative from "react-native-web";
|
|
5
5
|
import { makeList } from "./list-view.js";
|
|
6
6
|
|
|
7
|
-
export const { List, ListRow, EmptyState } = makeList(ReactNative);
|
|
7
|
+
export const { List, ListRow, ListHeader, EmptyState } = makeList(ReactNative);
|
package/dist/list.native.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// `<List>`, `<ListRow>`, `<EmptyState>` (native). Binds
|
|
2
|
-
// React Native's own primitives; only the import differs from ./list.js.
|
|
1
|
+
// `<List>`, `<ListRow>`, `<ListHeader>`, `<EmptyState>` (native). Binds
|
|
2
|
+
// ./list-view.js to React Native's own primitives; only the import differs from ./list.js.
|
|
3
3
|
|
|
4
|
-
import { View, Text, Pressable } from "react-native";
|
|
4
|
+
import { View, Text, Pressable, StyleSheet } from "react-native";
|
|
5
5
|
import { makeList } from "./list-view.js";
|
|
6
6
|
|
|
7
|
-
export const { List, ListRow, EmptyState } = makeList({ View, Text, Pressable });
|
|
7
|
+
export const { List, ListRow, ListHeader, EmptyState } = makeList({ View, Text, Pressable, StyleSheet });
|
package/dist/webbundle.js
CHANGED
|
@@ -90,12 +90,15 @@ function cssInjectPlugin() {
|
|
|
90
90
|
* packer passes the frontend's `node_modules` (where the vetted web packages
|
|
91
91
|
* are installed per the adding-vetted-packages skill) so the bundle resolves
|
|
92
92
|
* them no matter the invoking CWD.
|
|
93
|
+
* @param {boolean} [opts.minify] minify the output for production serving.
|
|
94
|
+
* Off by default so the dev server's bundle stays readable.
|
|
93
95
|
* @returns {Promise<string>} the bundled ESM source.
|
|
94
96
|
*/
|
|
95
97
|
export async function bundleWebEntry({
|
|
96
98
|
entryPath,
|
|
97
99
|
external = hostExternalSpecifiers(),
|
|
98
100
|
nodePaths = [],
|
|
101
|
+
minify = false,
|
|
99
102
|
}) {
|
|
100
103
|
const esbuild = await loadEsbuild();
|
|
101
104
|
const result = await esbuild.build({
|
|
@@ -138,6 +141,7 @@ export async function bundleWebEntry({
|
|
|
138
141
|
// adds extra roots so a packer invoked from the repo root still resolves the
|
|
139
142
|
// vetted web packages installed under frontend/node_modules.
|
|
140
143
|
nodePaths,
|
|
144
|
+
minify,
|
|
141
145
|
logLevel: "silent",
|
|
142
146
|
});
|
|
143
147
|
const out = result.outputFiles?.[0];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.172.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/webbundle.js
CHANGED
|
@@ -90,12 +90,15 @@ function cssInjectPlugin() {
|
|
|
90
90
|
* packer passes the frontend's `node_modules` (where the vetted web packages
|
|
91
91
|
* are installed per the adding-vetted-packages skill) so the bundle resolves
|
|
92
92
|
* them no matter the invoking CWD.
|
|
93
|
+
* @param {boolean} [opts.minify] minify the output for production serving.
|
|
94
|
+
* Off by default so the dev server's bundle stays readable.
|
|
93
95
|
* @returns {Promise<string>} the bundled ESM source.
|
|
94
96
|
*/
|
|
95
97
|
export async function bundleWebEntry({
|
|
96
98
|
entryPath,
|
|
97
99
|
external = hostExternalSpecifiers(),
|
|
98
100
|
nodePaths = [],
|
|
101
|
+
minify = false,
|
|
99
102
|
}) {
|
|
100
103
|
const esbuild = await loadEsbuild();
|
|
101
104
|
const result = await esbuild.build({
|
|
@@ -138,6 +141,7 @@ export async function bundleWebEntry({
|
|
|
138
141
|
// adds extra roots so a packer invoked from the repo root still resolves the
|
|
139
142
|
// vetted web packages installed under frontend/node_modules.
|
|
140
143
|
nodePaths,
|
|
144
|
+
minify,
|
|
141
145
|
logLevel: "silent",
|
|
142
146
|
});
|
|
143
147
|
const out = result.outputFiles?.[0];
|