@colixsystems/widget-sdk 0.161.0 → 0.163.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 +37 -1
- package/dist/contract.cjs +99 -1
- package/dist/contract.js +99 -1
- package/dist/index.d.ts +111 -0
- package/dist/index.js +17 -0
- package/dist/index.native.js +17 -0
- package/dist/list-tokens.js +68 -0
- package/dist/list-view.js +215 -0
- package/dist/list.js +7 -0
- package/dist/list.native.js +7 -0
- package/dist/tabs-tokens.js +75 -0
- package/dist/tabs-view.js +169 -0
- package/dist/tabs.js +7 -0
- package/dist/tabs.native.js +7 -0
- package/dist/text-roles.js +102 -0
- package/dist/theme-components.cjs +5 -0
- package/dist/theme-components.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,40 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.163.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.163.0 (contract 1.126.0)
|
|
79
|
+
|
|
80
|
+
**A themed tab strip: `Tabs`, and the `tabs` theme scope.** `<Tabs items={[{ key, label, icon? }]} value={tab} onChange={setTab} />` draws a strip whose look — a segmented control, pills, or an underline — and whose colours and corners come from **Theme Settings > Tabs** (`themeConfig.components.tabs`). It draws the strip only; render the selected panel yourself. The page Tabs container now renders through the same component on the web Player and in the exported app, so a page's tabs and a widget's tabs match, and the two hosts no longer draw different strips. Unthemed, the strip is the segmented control the exported app always drew.
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
const [tab, setTab] = useState("open");
|
|
84
|
+
<Tabs items={[{ key: "open", label: "Open" }, { key: "done", label: "Done", icon: "check" }]} value={tab} onChange={setTab} />
|
|
85
|
+
{tab === "open" ? <OpenOrders /> : <DoneOrders />}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Pass `variant` ("segmented" | "pill" | "underline"), `activeColor`, `activeTextColor`, `textColor` or `radius` to override one strip, or declare the style fields `tabsStyle`, `tabsActiveColor`, `tabsActiveTextColor`, `tabsTextColor` and `tabsRadius` so an author can.
|
|
89
|
+
|
|
90
|
+
The component-token vocabulary gains an **`enum`** type (`{ type: "enum", values }`), first used by `tabs.style`; `CONTRACT.themeComponentTabStyles` lists the styles.
|
|
91
|
+
|
|
92
|
+
### What's new in 0.162.0 (contract 1.125.0)
|
|
93
|
+
|
|
94
|
+
**Themed list rows: `List`, `ListRow`, `EmptyState`, and the `list` theme scope.** Each widget that showed records used to hand-roll its rows from `View` + `Pressable`, so two lists in one app disagreed on padding, dividers and corners. `<ListRow>` draws one row whose padding, minimum height, divider, corner radius and background come from **Theme Settings > Lists** (`themeConfig.components.list`), and `<List>` spaces its rows and passes any override down to them. Unthemed, a row is the flush, hairline band the built-in Data List always drew. When the theme sets a `gap`, rows become separate tiles on the surface, outlined by the divider. A selected row carries an accent bar as well as the tint, the last row of a `<List>` has no divider, a pressable row never drops below 44px, and every override passes the same bounds a theme value does. `<EmptyState>` is the matching "nothing here" panel.
|
|
95
|
+
|
|
96
|
+
```jsx
|
|
97
|
+
<List>
|
|
98
|
+
{items.map((item) => (
|
|
99
|
+
<ListRow key={item.id} title={item.name} subtitle={item.email} onPress={() => open(item)} selected={item.id === selectedId} />
|
|
100
|
+
))}
|
|
101
|
+
</List>
|
|
102
|
+
{items.length === 0 ? <EmptyState icon="inbox" title="No orders yet" message="New orders appear here." /> : null}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
To let an author restyle one widget's rows, declare the style fields `listRowPadding`, `listRowMinHeight`, `listRowGap`, `listRowRadius`, `listRowBackground`, `listDividerColor` and `listDividerWidth`. They follow the theme until set, like the `card*` fields do, and you pass them through as the matching `ListRow` props (`rowPadding`, …).
|
|
106
|
+
|
|
107
|
+
**`textRoleStyle(role, theme.typography, theme.colors)`** returns the complete style of a semantic text tier (`display`, `title`, `subtitle`, `body`, `caption`, `overline`) — size, absolute line height, weight, tracking, case, the tier's face and its colour. The built-in widgets use the same function, so your text matches theirs.
|
|
108
|
+
|
|
109
|
+
Additive: a theme without a `list` scope renders exactly as before.
|
|
77
110
|
|
|
78
111
|
### What's new in 0.161.0 (contract 1.124.0)
|
|
79
112
|
|
|
@@ -1767,6 +1800,9 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1767
1800
|
- `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`.
|
|
1768
1801
|
- `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`).
|
|
1769
1802
|
- `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.
|
|
1803
|
+
- `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`.
|
|
1804
|
+
- `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.
|
|
1805
|
+
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
1770
1806
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
1771
1807
|
|
|
1772
1808
|
## Design & visual polish
|
package/dist/contract.cjs
CHANGED
|
@@ -228,6 +228,9 @@ function isThemeColorTokenRef(value) {
|
|
|
228
228
|
// Single source for four consumers — the build runner's set_theme coercion, the
|
|
229
229
|
// shared host resolver, the planner prompt, and the SDK docs — so the vocabulary
|
|
230
230
|
// cannot drift between what Mason may emit and what a host actually applies.
|
|
231
|
+
// The looks a `tabs.style` token may name; `<Tabs>` draws each.
|
|
232
|
+
const THEME_TAB_STYLES = Object.freeze(["segmented", "pill", "underline"]);
|
|
233
|
+
|
|
231
234
|
const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
|
|
232
235
|
|
|
233
236
|
// sc-3857 — the `textTransform` token's closed enum, matching the per-instance
|
|
@@ -433,6 +436,28 @@ const DANGER_BUTTON_FIELDS = Object.freeze({
|
|
|
433
436
|
borderColor: "dangerBorderColor",
|
|
434
437
|
});
|
|
435
438
|
|
|
439
|
+
// The list-row vocabulary. Bound by name as well as to the Data List, so a
|
|
440
|
+
// custom widget that declares `listRowPadding` follows "Lists" like a built-in.
|
|
441
|
+
const LIST_ROW_FIELDS = Object.freeze({
|
|
442
|
+
rowPadding: "listRowPadding",
|
|
443
|
+
rowMinHeight: "listRowMinHeight",
|
|
444
|
+
gap: "listRowGap",
|
|
445
|
+
radius: "listRowRadius",
|
|
446
|
+
background: "listRowBackground",
|
|
447
|
+
dividerColor: "listDividerColor",
|
|
448
|
+
dividerWidth: "listDividerWidth",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// The tab-strip vocabulary. Bound by name as well as onto User Management,
|
|
452
|
+
// whose Users / Groups / Invites strip is drawn by the SDK <Tabs>.
|
|
453
|
+
const TABS_FIELDS = Object.freeze({
|
|
454
|
+
style: "tabsStyle",
|
|
455
|
+
activeColor: "tabsActiveColor",
|
|
456
|
+
activeTextColor: "tabsActiveTextColor",
|
|
457
|
+
textColor: "tabsTextColor",
|
|
458
|
+
radius: "tabsRadius",
|
|
459
|
+
});
|
|
460
|
+
|
|
436
461
|
const THEME_COMPONENTS = Object.freeze({
|
|
437
462
|
button: Object.freeze({
|
|
438
463
|
label: "Buttons",
|
|
@@ -584,6 +609,40 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
584
609
|
"appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
|
|
585
610
|
}),
|
|
586
611
|
}),
|
|
612
|
+
// Record rows: `<ListRow>` reads this scope directly; the fields bind it
|
|
613
|
+
// onto widgets. No uiDefault on the height: it has no theme token.
|
|
614
|
+
list: Object.freeze({
|
|
615
|
+
label: "Lists",
|
|
616
|
+
universalFields: LIST_ROW_FIELDS,
|
|
617
|
+
tokens: Object.freeze({
|
|
618
|
+
rowPadding: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "spacing.md" }),
|
|
619
|
+
rowMinHeight: Object.freeze({ type: "size", min: 32, max: 96 }),
|
|
620
|
+
gap: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
621
|
+
radius: Object.freeze({ type: "size", min: 0, max: 32 }),
|
|
622
|
+
background: Object.freeze({ type: "color" }),
|
|
623
|
+
dividerColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
624
|
+
dividerWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
|
|
625
|
+
}),
|
|
626
|
+
targets: Object.freeze({
|
|
627
|
+
"appstudio.data-list": LIST_ROW_FIELDS,
|
|
628
|
+
}),
|
|
629
|
+
}),
|
|
630
|
+
// Tab strips: `<Tabs>` and the page Tabs container read this scope directly;
|
|
631
|
+
// the fields bind it onto widgets. `style` is the first `enum` token.
|
|
632
|
+
tabs: Object.freeze({
|
|
633
|
+
label: "Tabs",
|
|
634
|
+
universalFields: TABS_FIELDS,
|
|
635
|
+
tokens: Object.freeze({
|
|
636
|
+
style: Object.freeze({ type: "enum", values: THEME_TAB_STYLES }),
|
|
637
|
+
activeColor: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
|
|
638
|
+
activeTextColor: Object.freeze({ type: "color" }),
|
|
639
|
+
textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
|
|
640
|
+
radius: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
641
|
+
}),
|
|
642
|
+
targets: Object.freeze({
|
|
643
|
+
"appstudio.user-management": TABS_FIELDS,
|
|
644
|
+
}),
|
|
645
|
+
}),
|
|
587
646
|
});
|
|
588
647
|
|
|
589
648
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -2173,6 +2232,34 @@ const PRIMITIVES = [
|
|
|
2173
2232
|
rnComponent: null,
|
|
2174
2233
|
docsUrl: null,
|
|
2175
2234
|
},
|
|
2235
|
+
{
|
|
2236
|
+
name: "List",
|
|
2237
|
+
description:
|
|
2238
|
+
"Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. Props: those overrides, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
|
|
2239
|
+
rnComponent: null,
|
|
2240
|
+
docsUrl: null,
|
|
2241
|
+
},
|
|
2242
|
+
{
|
|
2243
|
+
name: "ListRow",
|
|
2244
|
+
description:
|
|
2245
|
+
'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, for a multi-column row), `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.',
|
|
2246
|
+
rnComponent: null,
|
|
2247
|
+
docsUrl: null,
|
|
2248
|
+
},
|
|
2249
|
+
{
|
|
2250
|
+
name: "EmptyState",
|
|
2251
|
+
description:
|
|
2252
|
+
'Themed empty / zero-data state. `<EmptyState icon="inbox" title="No orders yet" message="New orders appear here." />`. THE way to say "nothing here" — a muted panel with an icon, a title and a caption, centred, in the app\'s own surface and text tiers. Props: `icon` (a lucide icon name, default "inbox"; pass null for none), `title`, `message`, `children` (an action, e.g. a button), `style`, `testID`.',
|
|
2253
|
+
rnComponent: null,
|
|
2254
|
+
docsUrl: null,
|
|
2255
|
+
},
|
|
2256
|
+
{
|
|
2257
|
+
name: "Tabs",
|
|
2258
|
+
description:
|
|
2259
|
+
'Themed tab strip. `<Tabs items={[{ key: "open", label: "Open" }, { key: "done", label: "Done", icon: "check" }]} value={tab} onChange={setTab} />`. THE way to switch between views inside a widget: its look (a segmented control, pills, or an underline — Theme Settings > Tabs) and its colours and corner radius come from the theme, so every tab strip in the app matches, including the page Tabs container. It draws the strip only; render the selected panel yourself below it. Never hand-roll tabs from `Pressable` chips. Props: `items` (`[{ key, label, icon?, disabled?, testID? }]`, icon is a lucide name), `value` (the selected key), `onChange(key)`, `accessibilityLabel`, `style`, `testID`, and the overrides `variant` ("segmented" | "pill" | "underline"), `activeColor`, `activeTextColor`, `textColor`, `radius`. The strip scrolls sideways when the tabs overflow. Declare `tabsStyle` / `tabsActiveColor` / `tabsActiveTextColor` / `tabsTextColor` / `tabsRadius` style fields and pass them through to let an author override one widget.',
|
|
2260
|
+
rnComponent: null,
|
|
2261
|
+
docsUrl: null,
|
|
2262
|
+
},
|
|
2176
2263
|
];
|
|
2177
2264
|
|
|
2178
2265
|
const CATEGORIES = [
|
|
@@ -4357,7 +4444,17 @@ const CONTRACT = deepFreeze({
|
|
|
4357
4444
|
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4358
4445
|
// keep their data while a changed query or refetch runs; `loading` only
|
|
4359
4446
|
// until the first result for the table/record, new `fetching` flag.
|
|
4360
|
-
|
|
4447
|
+
// 1.125.0: additive — the `list` component scope (row padding, height, gap,
|
|
4448
|
+
// radius, background, divider) and the `List` / `ListRow` / `EmptyState`
|
|
4449
|
+
// primitives that read it, plus `textRoleStyle` and its role helpers.
|
|
4450
|
+
// Widgets each hand-rolled their rows, so two lists in one app disagreed.
|
|
4451
|
+
// Additive: a theme without `list` paints the rows exactly as before.
|
|
4452
|
+
// 1.126.0: additive — the `tabs` component scope and the `Tabs` primitive
|
|
4453
|
+
// that reads it, and the `enum` component-token type (`values`), first
|
|
4454
|
+
// used by `tabs.style`. The page Tabs container renders through `Tabs` on
|
|
4455
|
+
// both hosts. Additive: an unthemed strip is the segmented control the
|
|
4456
|
+
// export always drew.
|
|
4457
|
+
version: "1.126.0",
|
|
4361
4458
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4362
4459
|
hooks: HOOKS,
|
|
4363
4460
|
primitives: PRIMITIVES,
|
|
@@ -4370,6 +4467,7 @@ const CONTRACT = deepFreeze({
|
|
|
4370
4467
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
4371
4468
|
themeColorTokens: THEME_COLOR_TOKENS,
|
|
4372
4469
|
themeComponents: THEME_COMPONENTS,
|
|
4470
|
+
themeComponentTabStyles: THEME_TAB_STYLES,
|
|
4373
4471
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
4374
4472
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
4375
4473
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
package/dist/contract.js
CHANGED
|
@@ -228,6 +228,9 @@ function isThemeColorTokenRef(value) {
|
|
|
228
228
|
// Single source for four consumers — the build runner's set_theme coercion, the
|
|
229
229
|
// shared host resolver, the planner prompt, and the SDK docs — so the vocabulary
|
|
230
230
|
// cannot drift between what Mason may emit and what a host actually applies.
|
|
231
|
+
// The looks a `tabs.style` token may name; `<Tabs>` draws each.
|
|
232
|
+
const THEME_TAB_STYLES = Object.freeze(["segmented", "pill", "underline"]);
|
|
233
|
+
|
|
231
234
|
const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
|
|
232
235
|
|
|
233
236
|
// sc-3857 — the `textTransform` token's closed enum, matching the per-instance
|
|
@@ -433,6 +436,28 @@ const DANGER_BUTTON_FIELDS = Object.freeze({
|
|
|
433
436
|
borderColor: "dangerBorderColor",
|
|
434
437
|
});
|
|
435
438
|
|
|
439
|
+
// The list-row vocabulary. Bound by name as well as to the Data List, so a
|
|
440
|
+
// custom widget that declares `listRowPadding` follows "Lists" like a built-in.
|
|
441
|
+
const LIST_ROW_FIELDS = Object.freeze({
|
|
442
|
+
rowPadding: "listRowPadding",
|
|
443
|
+
rowMinHeight: "listRowMinHeight",
|
|
444
|
+
gap: "listRowGap",
|
|
445
|
+
radius: "listRowRadius",
|
|
446
|
+
background: "listRowBackground",
|
|
447
|
+
dividerColor: "listDividerColor",
|
|
448
|
+
dividerWidth: "listDividerWidth",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// The tab-strip vocabulary. Bound by name as well as onto User Management,
|
|
452
|
+
// whose Users / Groups / Invites strip is drawn by the SDK <Tabs>.
|
|
453
|
+
const TABS_FIELDS = Object.freeze({
|
|
454
|
+
style: "tabsStyle",
|
|
455
|
+
activeColor: "tabsActiveColor",
|
|
456
|
+
activeTextColor: "tabsActiveTextColor",
|
|
457
|
+
textColor: "tabsTextColor",
|
|
458
|
+
radius: "tabsRadius",
|
|
459
|
+
});
|
|
460
|
+
|
|
436
461
|
const THEME_COMPONENTS = Object.freeze({
|
|
437
462
|
button: Object.freeze({
|
|
438
463
|
label: "Buttons",
|
|
@@ -584,6 +609,40 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
584
609
|
"appstudio.button": Object.freeze({ color: "ringColor", width: "ringWidth" }),
|
|
585
610
|
}),
|
|
586
611
|
}),
|
|
612
|
+
// Record rows: `<ListRow>` reads this scope directly; the fields bind it
|
|
613
|
+
// onto widgets. No uiDefault on the height: it has no theme token.
|
|
614
|
+
list: Object.freeze({
|
|
615
|
+
label: "Lists",
|
|
616
|
+
universalFields: LIST_ROW_FIELDS,
|
|
617
|
+
tokens: Object.freeze({
|
|
618
|
+
rowPadding: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "spacing.md" }),
|
|
619
|
+
rowMinHeight: Object.freeze({ type: "size", min: 32, max: 96 }),
|
|
620
|
+
gap: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
621
|
+
radius: Object.freeze({ type: "size", min: 0, max: 32 }),
|
|
622
|
+
background: Object.freeze({ type: "color" }),
|
|
623
|
+
dividerColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
624
|
+
dividerWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
|
|
625
|
+
}),
|
|
626
|
+
targets: Object.freeze({
|
|
627
|
+
"appstudio.data-list": LIST_ROW_FIELDS,
|
|
628
|
+
}),
|
|
629
|
+
}),
|
|
630
|
+
// Tab strips: `<Tabs>` and the page Tabs container read this scope directly;
|
|
631
|
+
// the fields bind it onto widgets. `style` is the first `enum` token.
|
|
632
|
+
tabs: Object.freeze({
|
|
633
|
+
label: "Tabs",
|
|
634
|
+
universalFields: TABS_FIELDS,
|
|
635
|
+
tokens: Object.freeze({
|
|
636
|
+
style: Object.freeze({ type: "enum", values: THEME_TAB_STYLES }),
|
|
637
|
+
activeColor: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
|
|
638
|
+
activeTextColor: Object.freeze({ type: "color" }),
|
|
639
|
+
textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
|
|
640
|
+
radius: Object.freeze({ type: "size", min: 0, max: 24 }),
|
|
641
|
+
}),
|
|
642
|
+
targets: Object.freeze({
|
|
643
|
+
"appstudio.user-management": TABS_FIELDS,
|
|
644
|
+
}),
|
|
645
|
+
}),
|
|
587
646
|
});
|
|
588
647
|
|
|
589
648
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -2173,6 +2232,34 @@ const PRIMITIVES = [
|
|
|
2173
2232
|
rnComponent: null,
|
|
2174
2233
|
docsUrl: null,
|
|
2175
2234
|
},
|
|
2235
|
+
{
|
|
2236
|
+
name: "List",
|
|
2237
|
+
description:
|
|
2238
|
+
"Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. Props: those overrides, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
|
|
2239
|
+
rnComponent: null,
|
|
2240
|
+
docsUrl: null,
|
|
2241
|
+
},
|
|
2242
|
+
{
|
|
2243
|
+
name: "ListRow",
|
|
2244
|
+
description:
|
|
2245
|
+
'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, for a multi-column row), `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.',
|
|
2246
|
+
rnComponent: null,
|
|
2247
|
+
docsUrl: null,
|
|
2248
|
+
},
|
|
2249
|
+
{
|
|
2250
|
+
name: "EmptyState",
|
|
2251
|
+
description:
|
|
2252
|
+
'Themed empty / zero-data state. `<EmptyState icon="inbox" title="No orders yet" message="New orders appear here." />`. THE way to say "nothing here" — a muted panel with an icon, a title and a caption, centred, in the app\'s own surface and text tiers. Props: `icon` (a lucide icon name, default "inbox"; pass null for none), `title`, `message`, `children` (an action, e.g. a button), `style`, `testID`.',
|
|
2253
|
+
rnComponent: null,
|
|
2254
|
+
docsUrl: null,
|
|
2255
|
+
},
|
|
2256
|
+
{
|
|
2257
|
+
name: "Tabs",
|
|
2258
|
+
description:
|
|
2259
|
+
'Themed tab strip. `<Tabs items={[{ key: "open", label: "Open" }, { key: "done", label: "Done", icon: "check" }]} value={tab} onChange={setTab} />`. THE way to switch between views inside a widget: its look (a segmented control, pills, or an underline — Theme Settings > Tabs) and its colours and corner radius come from the theme, so every tab strip in the app matches, including the page Tabs container. It draws the strip only; render the selected panel yourself below it. Never hand-roll tabs from `Pressable` chips. Props: `items` (`[{ key, label, icon?, disabled?, testID? }]`, icon is a lucide name), `value` (the selected key), `onChange(key)`, `accessibilityLabel`, `style`, `testID`, and the overrides `variant` ("segmented" | "pill" | "underline"), `activeColor`, `activeTextColor`, `textColor`, `radius`. The strip scrolls sideways when the tabs overflow. Declare `tabsStyle` / `tabsActiveColor` / `tabsActiveTextColor` / `tabsTextColor` / `tabsRadius` style fields and pass them through to let an author override one widget.',
|
|
2260
|
+
rnComponent: null,
|
|
2261
|
+
docsUrl: null,
|
|
2262
|
+
},
|
|
2176
2263
|
];
|
|
2177
2264
|
|
|
2178
2265
|
const CATEGORIES = [
|
|
@@ -4353,7 +4440,17 @@ const CONTRACT = deepFreeze({
|
|
|
4353
4440
|
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4354
4441
|
// keep their data while a changed query or refetch runs; `loading` only
|
|
4355
4442
|
// until the first result for the table/record, new `fetching` flag.
|
|
4356
|
-
|
|
4443
|
+
// 1.125.0: additive — the `list` component scope (row padding, height, gap,
|
|
4444
|
+
// radius, background, divider) and the `List` / `ListRow` / `EmptyState`
|
|
4445
|
+
// primitives that read it, plus `textRoleStyle` and its role helpers.
|
|
4446
|
+
// Widgets each hand-rolled their rows, so two lists in one app disagreed.
|
|
4447
|
+
// Additive: a theme without `list` paints the rows exactly as before.
|
|
4448
|
+
// 1.126.0: additive — the `tabs` component scope and the `Tabs` primitive
|
|
4449
|
+
// that reads it, and the `enum` component-token type (`values`), first
|
|
4450
|
+
// used by `tabs.style`. The page Tabs container renders through `Tabs` on
|
|
4451
|
+
// both hosts. Additive: an unthemed strip is the segmented control the
|
|
4452
|
+
// export always drew.
|
|
4453
|
+
version: "1.126.0",
|
|
4357
4454
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4358
4455
|
hooks: HOOKS,
|
|
4359
4456
|
primitives: PRIMITIVES,
|
|
@@ -4366,6 +4463,7 @@ const CONTRACT = deepFreeze({
|
|
|
4366
4463
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
4367
4464
|
themeColorTokens: THEME_COLOR_TOKENS,
|
|
4368
4465
|
themeComponents: THEME_COMPONENTS,
|
|
4466
|
+
themeComponentTabStyles: THEME_TAB_STYLES,
|
|
4369
4467
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
4370
4468
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
4371
4469
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
package/dist/index.d.ts
CHANGED
|
@@ -2553,6 +2553,117 @@ export const Overlay: (props: {
|
|
|
2553
2553
|
children?: ReactNode;
|
|
2554
2554
|
}) => any;
|
|
2555
2555
|
|
|
2556
|
+
/** Per-list row overrides; unset values follow Theme Settings > Lists. */
|
|
2557
|
+
export interface ListRowTokens {
|
|
2558
|
+
rowPadding?: number;
|
|
2559
|
+
rowMinHeight?: number;
|
|
2560
|
+
/** Space between rows. Above 0, rows are separate tiles on the muted surface. */
|
|
2561
|
+
gap?: number;
|
|
2562
|
+
radius?: number;
|
|
2563
|
+
background?: string;
|
|
2564
|
+
dividerColor?: string;
|
|
2565
|
+
dividerWidth?: number;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
/**
|
|
2569
|
+
* Themed list container. Spaces and insets its `<ListRow>` children the way the
|
|
2570
|
+
* app's look says, and hands its overrides down to every row.
|
|
2571
|
+
*/
|
|
2572
|
+
export const List: (props: ListRowTokens & {
|
|
2573
|
+
style?: any;
|
|
2574
|
+
testID?: string;
|
|
2575
|
+
children?: ReactNode;
|
|
2576
|
+
}) => any;
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* Themed list row. Padding, height, divider, radius and background come from
|
|
2580
|
+
* the theme's `list` scope, so every list in the app matches.
|
|
2581
|
+
*
|
|
2582
|
+
* @example
|
|
2583
|
+
* <List>
|
|
2584
|
+
* {items.map((item) => (
|
|
2585
|
+
* <ListRow key={item.id} title={item.name} subtitle={item.email} onPress={() => open(item)} />
|
|
2586
|
+
* ))}
|
|
2587
|
+
* </List>
|
|
2588
|
+
*/
|
|
2589
|
+
export const ListRow: (props: ListRowTokens & {
|
|
2590
|
+
title?: ReactNode;
|
|
2591
|
+
subtitle?: ReactNode;
|
|
2592
|
+
leading?: ReactNode;
|
|
2593
|
+
trailing?: ReactNode;
|
|
2594
|
+
/** Makes the row a button with a press state. */
|
|
2595
|
+
onPress?: () => void;
|
|
2596
|
+
selected?: boolean;
|
|
2597
|
+
disabled?: boolean;
|
|
2598
|
+
/** Defaults to true; pass false on the last row. */
|
|
2599
|
+
divider?: boolean;
|
|
2600
|
+
accessibilityLabel?: string;
|
|
2601
|
+
style?: any;
|
|
2602
|
+
testID?: string;
|
|
2603
|
+
/** Replaces the title block, e.g. for a multi-column row. */
|
|
2604
|
+
children?: ReactNode;
|
|
2605
|
+
}) => any;
|
|
2606
|
+
|
|
2607
|
+
/** Themed empty / zero-data state: icon, title and caption on the muted surface. */
|
|
2608
|
+
export const EmptyState: (props: {
|
|
2609
|
+
/** A lucide icon name; defaults to "inbox". Pass null for none. */
|
|
2610
|
+
icon?: string | null;
|
|
2611
|
+
title?: ReactNode;
|
|
2612
|
+
message?: ReactNode;
|
|
2613
|
+
style?: any;
|
|
2614
|
+
testID?: string;
|
|
2615
|
+
children?: ReactNode;
|
|
2616
|
+
}) => any;
|
|
2617
|
+
|
|
2618
|
+
export type TabStyle = "segmented" | "pill" | "underline";
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* Themed tab strip. Its look (segmented, pills or underline), colours and
|
|
2622
|
+
* corners come from the theme's `tabs` scope, so every strip in the app —
|
|
2623
|
+
* including the page Tabs container — matches. Draws the strip only; render
|
|
2624
|
+
* the selected panel yourself.
|
|
2625
|
+
*
|
|
2626
|
+
* @example
|
|
2627
|
+
* const [tab, setTab] = useState("open");
|
|
2628
|
+
* <Tabs items={[{ key: "open", label: "Open" }, { key: "done", label: "Done" }]} value={tab} onChange={setTab} />
|
|
2629
|
+
*/
|
|
2630
|
+
export const Tabs: (props: {
|
|
2631
|
+
items: Array<{ key: string; label: ReactNode; icon?: string; disabled?: boolean; testID?: string }>;
|
|
2632
|
+
value?: string;
|
|
2633
|
+
onChange?: (key: string) => void;
|
|
2634
|
+
/** Overrides the theme's tab style for this strip. */
|
|
2635
|
+
variant?: TabStyle;
|
|
2636
|
+
activeColor?: string;
|
|
2637
|
+
activeTextColor?: string;
|
|
2638
|
+
textColor?: string;
|
|
2639
|
+
radius?: number;
|
|
2640
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
2641
|
+
theme?: any;
|
|
2642
|
+
accessibilityLabel?: string;
|
|
2643
|
+
style?: any;
|
|
2644
|
+
testID?: string;
|
|
2645
|
+
}) => any;
|
|
2646
|
+
|
|
2647
|
+
export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
|
|
2648
|
+
export const TEXT_ROLES: readonly TextRole[];
|
|
2649
|
+
export const TEXT_TRANSFORMS: readonly string[];
|
|
2650
|
+
export const LINE_HEIGHT_RATIO: number;
|
|
2651
|
+
export const LEGACY_FONT_ROLE_ALIASES: Readonly<Record<string, TextRole>>;
|
|
2652
|
+
export const DEFAULT_TEXT_ROLE: TextRole;
|
|
2653
|
+
export const ROLE_FACES: Readonly<Record<TextRole, "heading" | "body">>;
|
|
2654
|
+
export function normaliseTextRole(value: unknown): TextRole;
|
|
2655
|
+
export function roleTokens(role: string, typography: any): Record<string, any> | null;
|
|
2656
|
+
export function roleColor(tokens: any, colors: any): string | undefined;
|
|
2657
|
+
/**
|
|
2658
|
+
* The full text style for a semantic tier — size, absolute line height, weight,
|
|
2659
|
+
* tracking, case, the tier's face and its colour — from the theme.
|
|
2660
|
+
*
|
|
2661
|
+
* @example
|
|
2662
|
+
* const theme = useTheme();
|
|
2663
|
+
* <Text style={textRoleStyle("caption", theme.typography, theme.colors)}>Updated 2m ago</Text>
|
|
2664
|
+
*/
|
|
2665
|
+
export function textRoleStyle(role: TextRole | string, typography: any, colors?: any): Record<string, any>;
|
|
2666
|
+
|
|
2556
2667
|
// ----------------------------------------------------- formatted content
|
|
2557
2668
|
// sc-6970 — widgets have no HTML path: they render through React Native
|
|
2558
2669
|
// primitives, which have no `dangerouslySetInnerHTML` on either host. Content
|
package/dist/index.js
CHANGED
|
@@ -189,6 +189,23 @@ export {
|
|
|
189
189
|
applyPendingReactions,
|
|
190
190
|
} from "./emoji-reactions.js";
|
|
191
191
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
192
|
+
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
193
|
+
export { List, ListRow, EmptyState } from "./list.js";
|
|
194
|
+
// A themed tab strip; follows the theme's `tabs` scope.
|
|
195
|
+
export { Tabs } from "./tabs.js";
|
|
196
|
+
// The semantic text tiers, shared with the built-in widgets.
|
|
197
|
+
export {
|
|
198
|
+
textRoleStyle,
|
|
199
|
+
TEXT_ROLES,
|
|
200
|
+
TEXT_TRANSFORMS,
|
|
201
|
+
LINE_HEIGHT_RATIO,
|
|
202
|
+
LEGACY_FONT_ROLE_ALIASES,
|
|
203
|
+
DEFAULT_TEXT_ROLE,
|
|
204
|
+
ROLE_FACES,
|
|
205
|
+
normaliseTextRole,
|
|
206
|
+
roleTokens,
|
|
207
|
+
roleColor,
|
|
208
|
+
} from "./text-roles.js";
|
|
192
209
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
193
210
|
export { pressableLift } from "./interaction.js";
|
|
194
211
|
export { styleGroup } from "./style-group.js";
|
package/dist/index.native.js
CHANGED
|
@@ -182,6 +182,23 @@ export {
|
|
|
182
182
|
applyPendingReactions,
|
|
183
183
|
} from "./emoji-reactions.js";
|
|
184
184
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
185
|
+
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
186
|
+
export { List, ListRow, EmptyState } from "./list.native.js";
|
|
187
|
+
// A themed tab strip; follows the theme's `tabs` scope.
|
|
188
|
+
export { Tabs } from "./tabs.native.js";
|
|
189
|
+
// The semantic text tiers, shared with the built-in widgets.
|
|
190
|
+
export {
|
|
191
|
+
textRoleStyle,
|
|
192
|
+
TEXT_ROLES,
|
|
193
|
+
TEXT_TRANSFORMS,
|
|
194
|
+
LINE_HEIGHT_RATIO,
|
|
195
|
+
LEGACY_FONT_ROLE_ALIASES,
|
|
196
|
+
DEFAULT_TEXT_ROLE,
|
|
197
|
+
ROLE_FACES,
|
|
198
|
+
normaliseTextRole,
|
|
199
|
+
roleTokens,
|
|
200
|
+
roleColor,
|
|
201
|
+
} from "./text-roles.js";
|
|
185
202
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
186
203
|
export { pressableLift } from "./interaction.native.js";
|
|
187
204
|
export { styleGroup } from "./style-group.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The values `<List>` / `<ListRow>` paint with, resolved from the theme's `list`
|
|
2
|
+
// component scope. Platform-free, like overlay-tokens.js, so both hosts render
|
|
3
|
+
// a row from the same numbers and tests can pin them without a renderer.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import { mixHex } from "./contract.js";
|
|
7
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
8
|
+
|
|
9
|
+
// A row with no themed height still meets the 44px touch-target floor.
|
|
10
|
+
export const LIST_ROW_MIN_HEIGHT = 44;
|
|
11
|
+
|
|
12
|
+
// The per-row overrides a widget may pass, named as the theme tokens are.
|
|
13
|
+
export const LIST_TOKEN_NAMES = Object.freeze([
|
|
14
|
+
"rowPadding",
|
|
15
|
+
"rowMinHeight",
|
|
16
|
+
"gap",
|
|
17
|
+
"radius",
|
|
18
|
+
"background",
|
|
19
|
+
"dividerColor",
|
|
20
|
+
"dividerWidth",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isSet(value) {
|
|
24
|
+
return value !== undefined && value !== null && value !== "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pick(overrides, scope, name) {
|
|
28
|
+
if (overrides && isSet(overrides[name])) return overrides[name];
|
|
29
|
+
if (scope && isSet(scope[name])) return scope[name];
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve one list's row values: explicit override, then the theme's `list`
|
|
35
|
+
* scope, then the theme tokens. Both layers pass the contract's own bounds, so
|
|
36
|
+
* a hand-edited or planned value cannot reach a native style unvalidated.
|
|
37
|
+
* Unthemed, a row is the flush band with a 1px hairline the Data List drew;
|
|
38
|
+
* a `gap` makes separate tiles on the surface, outlined by the divider.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
41
|
+
* @param {object} [overrides] — per-list values keyed by LIST_TOKEN_NAMES.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveListTokens(theme, overrides) {
|
|
44
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
45
|
+
const fallback = DEFAULT_THEME_TOKENS;
|
|
46
|
+
const colors = base.colors || fallback.colors;
|
|
47
|
+
const spacing = base.spacing || fallback.spacing;
|
|
48
|
+
const scope = base.components && base.components.list;
|
|
49
|
+
const own = normaliseThemeComponents({ list: overrides }).list;
|
|
50
|
+
|
|
51
|
+
const gap = pick(own, scope, "gap") ?? 0;
|
|
52
|
+
const separated = gap > 0;
|
|
53
|
+
const background = pick(own, scope, "background") ?? (separated ? colors.surface : undefined);
|
|
54
|
+
return {
|
|
55
|
+
rowPadding: pick(own, scope, "rowPadding") ?? spacing.md,
|
|
56
|
+
rowMinHeight: pick(own, scope, "rowMinHeight") ?? LIST_ROW_MIN_HEIGHT,
|
|
57
|
+
gap,
|
|
58
|
+
separated,
|
|
59
|
+
// A flush band has square ends; a divider would curve into round ones.
|
|
60
|
+
radius: separated ? pick(own, scope, "radius") ?? 0 : 0,
|
|
61
|
+
background,
|
|
62
|
+
dividerColor: pick(own, scope, "dividerColor") ?? colors.border,
|
|
63
|
+
dividerWidth: pick(own, scope, "dividerWidth") ?? 1,
|
|
64
|
+
selectedBackground: colors.primarySoft || colors.surfaceMuted,
|
|
65
|
+
selectedAccent: colors.primary,
|
|
66
|
+
pressedBackground: mixHex(colors.onSurface, background || colors.surface, 0.94),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The ONE implementation behind the `<List>`, `<ListRow>` and `<EmptyState>`
|
|
2
|
+
// SDK primitives. The platform bindings (`list.js` / `list.native.js`) differ
|
|
3
|
+
// only in where the primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Every widget drawing a list of records used to hand-roll its rows, so two
|
|
6
|
+
// widgets in one app disagreed on padding, dividers and radius. These read the
|
|
7
|
+
// theme's `list` scope, which a look sets once for the whole app.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { Icon } from "./icon.js";
|
|
13
|
+
import { resolveListTokens, LIST_TOKEN_NAMES } from "./list-tokens.js";
|
|
14
|
+
import { textRoleStyle } from "./text-roles.js";
|
|
15
|
+
|
|
16
|
+
// Outside a widget host (a test, a preview) the defaults still paint a row.
|
|
17
|
+
function useListTheme() {
|
|
18
|
+
return useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listOverrides(props) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const name of LIST_TOKEN_NAMES) {
|
|
24
|
+
if (props[name] !== undefined && props[name] !== null) out[name] = props[name];
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bind the shared list components to one platform's primitives.
|
|
31
|
+
*
|
|
32
|
+
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
33
|
+
*/
|
|
34
|
+
export function makeList(rn) {
|
|
35
|
+
const { View, Text, Pressable } = rn;
|
|
36
|
+
const ListContext = React.createContext(null);
|
|
37
|
+
|
|
38
|
+
function List(props) {
|
|
39
|
+
const { children, style, testID } = props;
|
|
40
|
+
const theme = useListTheme();
|
|
41
|
+
const inherited = React.useContext(ListContext);
|
|
42
|
+
const overrides = { ...inherited, ...listOverrides(props) };
|
|
43
|
+
const tokens = resolveListTokens(theme, overrides);
|
|
44
|
+
// No divider under the last row: the list's own edge ends it.
|
|
45
|
+
const rows = React.Children.toArray(children);
|
|
46
|
+
const last = rows.length - 1;
|
|
47
|
+
const content = rows.map((row, i) =>
|
|
48
|
+
i === last && React.isValidElement(row) && row.props.divider === undefined
|
|
49
|
+
? React.cloneElement(row, { divider: false })
|
|
50
|
+
: row,
|
|
51
|
+
);
|
|
52
|
+
return React.createElement(
|
|
53
|
+
ListContext.Provider,
|
|
54
|
+
{ value: overrides },
|
|
55
|
+
React.createElement(View, { style: [{ gap: tokens.gap }, style], testID }, content),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
List.displayName = "List";
|
|
59
|
+
|
|
60
|
+
function ListRow(props) {
|
|
61
|
+
const {
|
|
62
|
+
title,
|
|
63
|
+
subtitle,
|
|
64
|
+
leading,
|
|
65
|
+
trailing,
|
|
66
|
+
onPress,
|
|
67
|
+
selected = false,
|
|
68
|
+
disabled = false,
|
|
69
|
+
divider = true,
|
|
70
|
+
accessibilityLabel,
|
|
71
|
+
style,
|
|
72
|
+
testID,
|
|
73
|
+
children,
|
|
74
|
+
} = props;
|
|
75
|
+
const theme = useListTheme();
|
|
76
|
+
const inherited = React.useContext(ListContext);
|
|
77
|
+
const tokens = resolveListTokens(theme, {
|
|
78
|
+
...inherited,
|
|
79
|
+
...listOverrides(props),
|
|
80
|
+
});
|
|
81
|
+
const { colors, spacing, typography } = theme;
|
|
82
|
+
|
|
83
|
+
const pressable = typeof onPress === "function";
|
|
84
|
+
// A tile is outlined; a flush band is separated by its bottom divider.
|
|
85
|
+
const edge = tokens.separated
|
|
86
|
+
? tokens.dividerWidth > 0
|
|
87
|
+
? { borderWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
88
|
+
: null
|
|
89
|
+
: divider && tokens.dividerWidth > 0
|
|
90
|
+
? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
91
|
+
: null;
|
|
92
|
+
const rowStyle = {
|
|
93
|
+
flexDirection: "row",
|
|
94
|
+
alignItems: "center",
|
|
95
|
+
gap: spacing.md,
|
|
96
|
+
paddingHorizontal: tokens.rowPadding,
|
|
97
|
+
paddingVertical: tokens.rowPadding,
|
|
98
|
+
// A dense look may shrink a static row, never a tap target.
|
|
99
|
+
minHeight: pressable ? Math.max(tokens.rowMinHeight, 44) : tokens.rowMinHeight,
|
|
100
|
+
borderRadius: tokens.radius,
|
|
101
|
+
...(tokens.background ? { backgroundColor: tokens.background } : null),
|
|
102
|
+
...edge,
|
|
103
|
+
// A tint alone is too faint to read as the current row.
|
|
104
|
+
...(selected
|
|
105
|
+
? {
|
|
106
|
+
backgroundColor: tokens.selectedBackground,
|
|
107
|
+
borderLeftWidth: 3,
|
|
108
|
+
borderLeftColor: tokens.selectedAccent,
|
|
109
|
+
}
|
|
110
|
+
: null),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const body =
|
|
114
|
+
children !== undefined
|
|
115
|
+
? children
|
|
116
|
+
: React.createElement(
|
|
117
|
+
View,
|
|
118
|
+
{ style: { flex: 1, gap: 2 } },
|
|
119
|
+
title !== undefined && title !== null
|
|
120
|
+
? React.createElement(
|
|
121
|
+
Text,
|
|
122
|
+
{
|
|
123
|
+
style: textRoleStyle("subtitle", typography, colors),
|
|
124
|
+
numberOfLines: 1,
|
|
125
|
+
},
|
|
126
|
+
title,
|
|
127
|
+
)
|
|
128
|
+
: null,
|
|
129
|
+
subtitle !== undefined && subtitle !== null
|
|
130
|
+
? React.createElement(
|
|
131
|
+
Text,
|
|
132
|
+
{
|
|
133
|
+
style: textRoleStyle("caption", typography, colors),
|
|
134
|
+
numberOfLines: 2,
|
|
135
|
+
},
|
|
136
|
+
subtitle,
|
|
137
|
+
)
|
|
138
|
+
: null,
|
|
139
|
+
);
|
|
140
|
+
const content = [leading || null, body, trailing || null];
|
|
141
|
+
|
|
142
|
+
if (!pressable) {
|
|
143
|
+
return React.createElement(
|
|
144
|
+
View,
|
|
145
|
+
{
|
|
146
|
+
style: [rowStyle, style],
|
|
147
|
+
testID,
|
|
148
|
+
accessibilityLabel,
|
|
149
|
+
accessible: accessibilityLabel ? true : undefined,
|
|
150
|
+
},
|
|
151
|
+
...content,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return React.createElement(
|
|
155
|
+
Pressable,
|
|
156
|
+
{
|
|
157
|
+
onPress,
|
|
158
|
+
disabled,
|
|
159
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
160
|
+
accessibilityRole: "button",
|
|
161
|
+
accessibilityLabel,
|
|
162
|
+
"aria-selected": selected,
|
|
163
|
+
"aria-disabled": disabled,
|
|
164
|
+
testID,
|
|
165
|
+
style: ({ pressed }) => [
|
|
166
|
+
rowStyle,
|
|
167
|
+
pressed ? { backgroundColor: tokens.pressedBackground } : null,
|
|
168
|
+
disabled ? { opacity: 0.5 } : null,
|
|
169
|
+
style,
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
...content,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
ListRow.displayName = "ListRow";
|
|
176
|
+
|
|
177
|
+
function EmptyState({ icon = "inbox", title, message, children, style, testID }) {
|
|
178
|
+
const { colors, spacing, radii, typography } = useListTheme();
|
|
179
|
+
return React.createElement(
|
|
180
|
+
View,
|
|
181
|
+
{
|
|
182
|
+
style: [
|
|
183
|
+
{
|
|
184
|
+
padding: spacing.lg,
|
|
185
|
+
borderRadius: radii.md,
|
|
186
|
+
backgroundColor: colors.surfaceMuted,
|
|
187
|
+
alignItems: "center",
|
|
188
|
+
gap: spacing.sm,
|
|
189
|
+
},
|
|
190
|
+
style,
|
|
191
|
+
],
|
|
192
|
+
testID,
|
|
193
|
+
},
|
|
194
|
+
icon ? React.createElement(Icon, { name: icon, size: 20, color: colors.onSurfaceMuted }) : null,
|
|
195
|
+
title
|
|
196
|
+
? React.createElement(
|
|
197
|
+
Text,
|
|
198
|
+
{ style: { ...textRoleStyle("subtitle", typography, colors), textAlign: "center" } },
|
|
199
|
+
title,
|
|
200
|
+
)
|
|
201
|
+
: null,
|
|
202
|
+
message
|
|
203
|
+
? React.createElement(
|
|
204
|
+
Text,
|
|
205
|
+
{ style: { ...textRoleStyle("caption", typography, colors), textAlign: "center" } },
|
|
206
|
+
message,
|
|
207
|
+
)
|
|
208
|
+
: null,
|
|
209
|
+
children || null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
EmptyState.displayName = "EmptyState";
|
|
213
|
+
|
|
214
|
+
return { List, ListRow, EmptyState };
|
|
215
|
+
}
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<List>`, `<ListRow>`, `<EmptyState>` (web). Binds ./list-view.js to
|
|
2
|
+
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeList } from "./list-view.js";
|
|
6
|
+
|
|
7
|
+
export const { List, ListRow, EmptyState } = makeList(ReactNative);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<List>`, `<ListRow>`, `<EmptyState>` (native). Binds ./list-view.js to
|
|
2
|
+
// React Native's own primitives; only the import differs from ./list.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable } from "react-native";
|
|
5
|
+
import { makeList } from "./list-view.js";
|
|
6
|
+
|
|
7
|
+
export const { List, ListRow, EmptyState } = makeList({ View, Text, Pressable });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The values `<Tabs>` paints with, resolved from the theme's `tabs` component
|
|
2
|
+
// scope. Platform-free, like list-tokens.js, so both hosts draw one strip from
|
|
3
|
+
// the same numbers and tests can pin them without a renderer.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import { CONTRACT, contrastRatio } from "./contract.js";
|
|
7
|
+
|
|
8
|
+
export const TAB_STYLES = CONTRACT.themeComponentTabStyles;
|
|
9
|
+
|
|
10
|
+
// Segmented is the strip the exported app's Tabs container always drew.
|
|
11
|
+
export const DEFAULT_TAB_STYLE = "segmented";
|
|
12
|
+
export const DEFAULT_TAB_RADIUS = 6;
|
|
13
|
+
|
|
14
|
+
// Per-strip overrides a widget may pass, named as the theme tokens are.
|
|
15
|
+
export const TAB_TOKEN_NAMES = Object.freeze([
|
|
16
|
+
"style",
|
|
17
|
+
"activeColor",
|
|
18
|
+
"activeTextColor",
|
|
19
|
+
"textColor",
|
|
20
|
+
"radius",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isSet(value) {
|
|
24
|
+
return value !== undefined && value !== null && value !== "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pick(overrides, scope, name) {
|
|
28
|
+
if (overrides && isSet(overrides[name])) return overrides[name];
|
|
29
|
+
if (scope && isSet(scope[name])) return scope[name];
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve one tab strip: explicit override, then the theme's `tabs` scope,
|
|
35
|
+
* then the widget's `defaultStyle`, then the tokens. The active label is the
|
|
36
|
+
* accent on the surface styles, and readable on the accent for a pill.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
39
|
+
* @param {object} [overrides] — per-strip values keyed by TAB_TOKEN_NAMES.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveTabsTokens(theme, overrides) {
|
|
42
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
43
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
44
|
+
const scope = base.components && base.components.tabs;
|
|
45
|
+
|
|
46
|
+
// The widget's own default sits BELOW the theme: a look still wins.
|
|
47
|
+
const requested =
|
|
48
|
+
pick(overrides, scope, "style") ?? (overrides && overrides.defaultStyle);
|
|
49
|
+
const style = TAB_STYLES.includes(requested) ? requested : DEFAULT_TAB_STYLE;
|
|
50
|
+
const ownAccent = pick(overrides, scope, "activeColor");
|
|
51
|
+
const activeColor = ownAccent ?? colors.primary;
|
|
52
|
+
return {
|
|
53
|
+
style,
|
|
54
|
+
activeColor,
|
|
55
|
+
activeTextColor:
|
|
56
|
+
pick(overrides, scope, "activeTextColor") ??
|
|
57
|
+
(style === "pill"
|
|
58
|
+
? ownAccent
|
|
59
|
+
? readableOn(ownAccent, colors)
|
|
60
|
+
: colors.onPrimary
|
|
61
|
+
: activeColor),
|
|
62
|
+
textColor: pick(overrides, scope, "textColor") ?? colors.onSurfaceMuted,
|
|
63
|
+
radius: pick(overrides, scope, "radius") ?? DEFAULT_TAB_RADIUS,
|
|
64
|
+
barBackground: colors.surfaceMuted,
|
|
65
|
+
activeSurface: colors.surface,
|
|
66
|
+
rule: colors.border,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A chosen accent carries the label that reads best on it, dark or light.
|
|
71
|
+
function readableOn(fill, colors) {
|
|
72
|
+
const dark = colors.onSurface;
|
|
73
|
+
const light = "#ffffff";
|
|
74
|
+
return contrastRatio(fill, dark) >= contrastRatio(fill, light) ? dark : light;
|
|
75
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Tabs>` SDK primitive. The platform
|
|
2
|
+
// bindings (`tabs.js` / `tabs.native.js`) differ only in where the primitives
|
|
3
|
+
// come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// The page Tabs container, the built-in widgets and custom widgets each drew
|
|
6
|
+
// their own tab strip, so an app's tabs looked different on every screen and
|
|
7
|
+
// between web and native. This strip reads the theme's `tabs` scope.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { Icon } from "./icon.js";
|
|
13
|
+
import { resolveTabsTokens } from "./tabs-tokens.js";
|
|
14
|
+
|
|
15
|
+
const SEGMENT_INSET = 4;
|
|
16
|
+
|
|
17
|
+
function stripStyles(t, theme) {
|
|
18
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
19
|
+
const elevation = theme.elevation || DEFAULT_THEME_TOKENS.elevation;
|
|
20
|
+
if (t.style === "pill") {
|
|
21
|
+
// An idle chip is outlined: a muted fill vanishes on a white card.
|
|
22
|
+
return {
|
|
23
|
+
bar: null,
|
|
24
|
+
content: { gap: spacing.sm },
|
|
25
|
+
item: (active) => ({
|
|
26
|
+
borderRadius: t.radius,
|
|
27
|
+
borderWidth: 1,
|
|
28
|
+
borderColor: active ? t.activeColor : t.rule,
|
|
29
|
+
backgroundColor: active ? t.activeColor : "transparent",
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (t.style === "underline") {
|
|
34
|
+
// The rule sits on the content, so the scroller cannot clip the accent.
|
|
35
|
+
return {
|
|
36
|
+
bar: null,
|
|
37
|
+
content: { gap: spacing.lg, borderBottomWidth: 1, borderColor: t.rule },
|
|
38
|
+
item: (active) => ({
|
|
39
|
+
paddingHorizontal: 4,
|
|
40
|
+
borderBottomWidth: 2,
|
|
41
|
+
borderColor: active ? t.activeColor : "transparent",
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// A concentric outer radius keeps the inset chip's corners parallel. The
|
|
46
|
+
// bar hugs its segments, and the active one is outlined as well as lifted
|
|
47
|
+
// because a shadow alone disappears on a dark theme.
|
|
48
|
+
return {
|
|
49
|
+
bar: {
|
|
50
|
+
backgroundColor: t.barBackground,
|
|
51
|
+
borderRadius: t.radius + 2,
|
|
52
|
+
alignSelf: "flex-start",
|
|
53
|
+
maxWidth: "100%",
|
|
54
|
+
},
|
|
55
|
+
content: { padding: SEGMENT_INSET, gap: SEGMENT_INSET },
|
|
56
|
+
item: (active) => ({
|
|
57
|
+
borderRadius: t.radius,
|
|
58
|
+
borderWidth: 1,
|
|
59
|
+
borderColor: active ? t.rule : "transparent",
|
|
60
|
+
...(active
|
|
61
|
+
? { backgroundColor: t.activeSurface, ...(elevation.sm || {}) }
|
|
62
|
+
: null),
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bind the shared tab strip to one platform's primitives.
|
|
69
|
+
*
|
|
70
|
+
* @param {{View: any, Text: any, Pressable: any, ScrollView: any}} rn
|
|
71
|
+
*/
|
|
72
|
+
export function makeTabs(rn) {
|
|
73
|
+
const { Text, Pressable, ScrollView } = rn;
|
|
74
|
+
|
|
75
|
+
function Tabs({
|
|
76
|
+
items = [],
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
variant,
|
|
80
|
+
defaultVariant,
|
|
81
|
+
activeColor,
|
|
82
|
+
activeTextColor,
|
|
83
|
+
textColor,
|
|
84
|
+
radius,
|
|
85
|
+
theme: explicitTheme,
|
|
86
|
+
accessibilityLabel,
|
|
87
|
+
style,
|
|
88
|
+
testID,
|
|
89
|
+
}) {
|
|
90
|
+
const hostTheme = useHostTheme();
|
|
91
|
+
// Layout containers render outside a widget context, so they pass theirs.
|
|
92
|
+
const theme = explicitTheme || hostTheme || DEFAULT_THEME_TOKENS;
|
|
93
|
+
const t = resolveTabsTokens(theme, {
|
|
94
|
+
defaultStyle: defaultVariant,
|
|
95
|
+
style: variant,
|
|
96
|
+
activeColor,
|
|
97
|
+
activeTextColor,
|
|
98
|
+
textColor,
|
|
99
|
+
radius,
|
|
100
|
+
});
|
|
101
|
+
const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
|
|
102
|
+
const strip = stripStyles(t, theme);
|
|
103
|
+
|
|
104
|
+
return React.createElement(
|
|
105
|
+
ScrollView,
|
|
106
|
+
{
|
|
107
|
+
horizontal: true,
|
|
108
|
+
showsHorizontalScrollIndicator: false,
|
|
109
|
+
// A strip never grows into, or shrinks away from, the panel below.
|
|
110
|
+
style: [{ flexGrow: 0, flexShrink: 0 }, strip.bar, style],
|
|
111
|
+
contentContainerStyle: [{ flexDirection: "row" }, strip.content],
|
|
112
|
+
accessibilityRole: "tablist",
|
|
113
|
+
accessibilityLabel,
|
|
114
|
+
testID,
|
|
115
|
+
},
|
|
116
|
+
...items.map((item) => {
|
|
117
|
+
const active = item.key === value;
|
|
118
|
+
const ink = active ? t.activeTextColor : t.textColor;
|
|
119
|
+
return React.createElement(
|
|
120
|
+
Pressable,
|
|
121
|
+
{
|
|
122
|
+
key: item.key,
|
|
123
|
+
onPress: () => {
|
|
124
|
+
if (!active && typeof onChange === "function") onChange(item.key);
|
|
125
|
+
},
|
|
126
|
+
disabled: item.disabled,
|
|
127
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
128
|
+
accessibilityRole: "tab",
|
|
129
|
+
"aria-selected": active,
|
|
130
|
+
"aria-disabled": !!item.disabled,
|
|
131
|
+
testID: item.testID,
|
|
132
|
+
style: [
|
|
133
|
+
{
|
|
134
|
+
flexDirection: "row",
|
|
135
|
+
alignItems: "center",
|
|
136
|
+
justifyContent: "center",
|
|
137
|
+
gap: 6,
|
|
138
|
+
minHeight: 44,
|
|
139
|
+
paddingHorizontal: 12,
|
|
140
|
+
paddingVertical: 8,
|
|
141
|
+
},
|
|
142
|
+
strip.item(active),
|
|
143
|
+
item.disabled ? { opacity: 0.5 } : null,
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
item.icon
|
|
147
|
+
? React.createElement(Icon, { name: item.icon, size: 16, color: ink })
|
|
148
|
+
: null,
|
|
149
|
+
React.createElement(
|
|
150
|
+
Text,
|
|
151
|
+
{
|
|
152
|
+
numberOfLines: 1,
|
|
153
|
+
style: {
|
|
154
|
+
fontFamily: typography.fontFamily,
|
|
155
|
+
fontSize: typography.sizes.sm,
|
|
156
|
+
fontWeight: active ? "600" : "500",
|
|
157
|
+
color: ink,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
item.label,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
Tabs.displayName = "Tabs";
|
|
168
|
+
return Tabs;
|
|
169
|
+
}
|
package/dist/tabs.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Tabs>` (native). Binds ./tabs-view.js to React Native's own primitives;
|
|
2
|
+
// only the import differs from ./tabs.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable, ScrollView } from "react-native";
|
|
5
|
+
import { makeTabs } from "./tabs-view.js";
|
|
6
|
+
|
|
7
|
+
export const Tabs = makeTabs({ View, Text, Pressable, ScrollView });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// REQ-THEME-ROLE — the semantic text tiers, resolved against a theme.
|
|
2
|
+
//
|
|
3
|
+
// The ONE implementation behind every role-styled text: the built-in widgets
|
|
4
|
+
// (frontend `_shared/textStyle.js` re-exports it), the SDK's own themed
|
|
5
|
+
// components, and any custom widget that calls `textRoleStyle`. Platform-free,
|
|
6
|
+
// so both hosts and the exported app resolve a tier identically.
|
|
7
|
+
|
|
8
|
+
// Leading is a MULTIPLE of the font size; 1.4 is the platform body ratio.
|
|
9
|
+
export const LINE_HEIGHT_RATIO = 1.4;
|
|
10
|
+
|
|
11
|
+
export const TEXT_TRANSFORMS = ["none", "uppercase", "capitalize"];
|
|
12
|
+
|
|
13
|
+
// The role names the TIER; `theme.typography.roles` owns what it looks like.
|
|
14
|
+
export const TEXT_ROLES = [
|
|
15
|
+
"display",
|
|
16
|
+
"title",
|
|
17
|
+
"subtitle",
|
|
18
|
+
"body",
|
|
19
|
+
"caption",
|
|
20
|
+
"overline",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// sc-3862's `heading` value predates the tiers and must keep resolving.
|
|
24
|
+
export const LEGACY_FONT_ROLE_ALIASES = Object.freeze({ heading: "title" });
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_TEXT_ROLE = "body";
|
|
27
|
+
|
|
28
|
+
// Structural, not a token: the tier decides which of the two faces it takes.
|
|
29
|
+
export const ROLE_FACES = Object.freeze({
|
|
30
|
+
display: "heading",
|
|
31
|
+
title: "heading",
|
|
32
|
+
subtitle: "body",
|
|
33
|
+
body: "body",
|
|
34
|
+
caption: "body",
|
|
35
|
+
overline: "body",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function normaliseTextRole(value) {
|
|
39
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
40
|
+
const aliased = LEGACY_FONT_ROLE_ALIASES[raw] || raw;
|
|
41
|
+
return TEXT_ROLES.includes(aliased) ? aliased : DEFAULT_TEXT_ROLE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The role's token set as the host resolved it, or null. */
|
|
45
|
+
export function roleTokens(role, typography) {
|
|
46
|
+
const roles = typography && typography.roles;
|
|
47
|
+
const tokens = roles && roles[normaliseTextRole(role)];
|
|
48
|
+
return tokens || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The colour a role paints in: the theme's explicit override, else the surface
|
|
53
|
+
* token the role names — resolved per render so a dark surface recolours it.
|
|
54
|
+
*/
|
|
55
|
+
export function roleColor(tokens, colors) {
|
|
56
|
+
if (!tokens) return undefined;
|
|
57
|
+
const explicit = typeof tokens.color === "string" ? tokens.color.trim() : "";
|
|
58
|
+
if (explicit) return explicit;
|
|
59
|
+
if (!colors || typeof tokens.colorToken !== "string") return undefined;
|
|
60
|
+
return colors[tokens.colorToken];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The complete text style for a semantic tier: size, absolute line height,
|
|
65
|
+
* weight, tracking, case, the tier's face and its colour.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} role — one of TEXT_ROLES; a legacy `heading` is aliased.
|
|
68
|
+
* @param {object} typography — the resolved theme's typography tokens.
|
|
69
|
+
* @param {object} [colors] — the resolved theme's colours, for `colorToken`.
|
|
70
|
+
*/
|
|
71
|
+
export function textRoleStyle(role, typography, colors) {
|
|
72
|
+
const resolved = normaliseTextRole(role);
|
|
73
|
+
const tokens = roleTokens(resolved, typography) || {};
|
|
74
|
+
const size =
|
|
75
|
+
typeof tokens.fontSize === "number" && Number.isFinite(tokens.fontSize)
|
|
76
|
+
? tokens.fontSize
|
|
77
|
+
: undefined;
|
|
78
|
+
const ratio =
|
|
79
|
+
typeof tokens.lineHeight === "number" && Number.isFinite(tokens.lineHeight)
|
|
80
|
+
? tokens.lineHeight
|
|
81
|
+
: LINE_HEIGHT_RATIO;
|
|
82
|
+
const family = !typography
|
|
83
|
+
? undefined
|
|
84
|
+
: ROLE_FACES[resolved] === "heading"
|
|
85
|
+
? typography.headingFontFamily || typography.fontFamily
|
|
86
|
+
: typography.fontFamily;
|
|
87
|
+
const color = roleColor(tokens, colors);
|
|
88
|
+
return {
|
|
89
|
+
...(size ? { fontSize: size, lineHeight: Math.round(size * ratio) } : null),
|
|
90
|
+
...(tokens.fontWeight ? { fontWeight: tokens.fontWeight } : null),
|
|
91
|
+
...(tokens.fontStyle ? { fontStyle: tokens.fontStyle } : null),
|
|
92
|
+
...(typeof tokens.letterSpacing === "number" &&
|
|
93
|
+
Number.isFinite(tokens.letterSpacing)
|
|
94
|
+
? { letterSpacing: tokens.letterSpacing }
|
|
95
|
+
: null),
|
|
96
|
+
...(TEXT_TRANSFORMS.includes(tokens.textTransform)
|
|
97
|
+
? { textTransform: tokens.textTransform }
|
|
98
|
+
: null),
|
|
99
|
+
...(family ? { fontFamily: family } : null),
|
|
100
|
+
...(color ? { color } : null),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -65,6 +65,11 @@ function coerceToken(def, value) {
|
|
|
65
65
|
if (def.type === "gradient") {
|
|
66
66
|
return normaliseComponentGradient(value) || undefined;
|
|
67
67
|
}
|
|
68
|
+
if (def.type === "enum") {
|
|
69
|
+
return Array.isArray(def.values) && def.values.includes(value)
|
|
70
|
+
? value
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
68
73
|
return undefined;
|
|
69
74
|
}
|
|
70
75
|
|
package/dist/theme-components.js
CHANGED
|
@@ -57,6 +57,11 @@ function coerceToken(def, value) {
|
|
|
57
57
|
if (def.type === "gradient") {
|
|
58
58
|
return normaliseComponentGradient(value) || undefined;
|
|
59
59
|
}
|
|
60
|
+
if (def.type === "enum") {
|
|
61
|
+
return Array.isArray(def.values) && def.values.includes(value)
|
|
62
|
+
? value
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
60
65
|
return undefined;
|
|
61
66
|
}
|
|
62
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.163.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",
|