@colixsystems/widget-sdk 0.214.0 → 0.218.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 +29 -1
- package/dist/chip-roving.js +63 -0
- package/dist/chip-tokens.js +141 -0
- package/dist/chip-view.js +324 -0
- package/dist/chip.js +8 -0
- package/dist/chip.native.js +7 -0
- package/dist/contract.cjs +37 -1
- package/dist/contract.js +37 -1
- package/dist/host.d.ts +25 -0
- package/dist/host.js +5 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/navigation.cjs +28 -1
- package/dist/navigation.js +27 -0
- package/package.json +1 -1
- package/src/contract.js +37 -1
package/README.md
CHANGED
|
@@ -83,7 +83,34 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.218.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**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.218.0
|
|
89
|
+
|
|
90
|
+
**Host helpers for the app logo's dark-bar variant.** An app can now carry a second logo for dark navigation bars, stored beside the app logo as `themeConfig.appLogoDarkAssetId` / `appLogoDarkUrl`. Two helpers on `@colixsystems/widget-sdk/host` decide which one a bar draws, and both hosts ask them, so the rail, the drawer, the app header and the site header pick the same logo in the web Player and in the exported app:
|
|
91
|
+
|
|
92
|
+
- `isDarkSurface({ surface, backdrop, textColor, theme })` — `true` when the bar reads as dark. Pass the colour the bar paints as `surface`; a see-through `#RRGGBBAA` is composited over `backdrop`, or over the page in `theme`, first. A bar that paints nothing passes the `textColor` authored to sit on it instead: light text names a dark backdrop.
|
|
93
|
+
- `resolveAppLogo(logo, darkSurfaceLogo, onDark)` — the logo to draw. The variant is used only beside an app logo; it never stands in for a missing one.
|
|
94
|
+
|
|
95
|
+
These are host-integration helpers, not something a widget imports. Nothing changes for widget authors.
|
|
96
|
+
|
|
97
|
+
### What's new in 0.215.0 (contract 1.174.0)
|
|
98
|
+
|
|
99
|
+
**A themed chip: `Chip`, `ChipGroup`, and the `chip` theme scope.** `<Chip label selected onPress />` draws a filter chip, tag or pill whose fill, text colour, border, corner radius and selected colours come from **Theme Settings > Chips** (`themeConfig.components.chip`), so every chip in an app matches on the web Player and in the exported app. Pass `selected` with `onPress` for a chip that toggles (keep the selection in your own state), `onRemove` for a trailing remove button, `icon` for a lucide glyph before the label, and `disabled` to switch it off. A long label truncates on one line. `label` is a string or a number; pass `numberOfLines={0}` to let it wrap instead. `<ChipGroup>` spaces its chips and wraps them onto the next line on a narrow screen.
|
|
100
|
+
|
|
101
|
+
```jsx
|
|
102
|
+
const [status, setStatus] = useState("open");
|
|
103
|
+
<ChipGroup selection="single" accessibilityLabel="Status">
|
|
104
|
+
{["open", "done"].map((s) => (
|
|
105
|
+
<Chip key={s} label={s} selected={status === s} onPress={() => setStatus(s)} />
|
|
106
|
+
))}
|
|
107
|
+
</ChipGroup>
|
|
108
|
+
<Chip label="Design" icon="tag" onRemove={() => removeTag("Design")} />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Inside a `<ChipGroup>` with a `selection`, every chip with `onPress` is a choice whether picked or not: a radio in a single-choice group, otherwise a checkbox, with its checked state. Outside one, a chip given a boolean `selected` is a checkbox and a chip with only `onPress` is a button. On the web, Space and Enter press a chip, and a single-choice group is one tab stop whose arrow keys move the choice. The remove control is its own button labelled `Remove <label>`; pass `removeAccessibilityLabel` with your translated text. `size` is `"md"` (default, a 44px touch target) or `"sm"` (a compact 28px chip for a dense toolbar); `variant` is `"filled"` (default) or `"outlined"`. Pass `background`, `textColor`, `borderColor`, `radius`, `selectedBackground` or `selectedTextColor` (hex colours) to override one chip; a fill set without its text colour gets the dark or light label that reads on it. Unthemed, a chip is a muted pill with no border, and the selected one takes the primary colour. A status nobody taps stays a `<Badge>`.
|
|
112
|
+
|
|
113
|
+
A component scope can now name the SDK components that read it directly (`sdkComponents`) instead of binding widget style fields; `chip` is the first.
|
|
87
114
|
|
|
88
115
|
### What's new in 0.214.0 (contract 1.173.0)
|
|
89
116
|
|
|
@@ -2617,6 +2644,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2617
2644
|
- `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
|
|
2618
2645
|
- `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, single-choice picker and search box. Like `TextField` they follow the theme's `muted` scope (and `popover` for the Select's option sheet) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level. Never hand-roll one from `Pressable` and `View`.
|
|
2619
2646
|
- `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.
|
|
2647
|
+
- `Chip`, `ChipGroup` — the themed filter chip, tag or pill. `<Chip label selected onPress onRemove disabled>` follows the theme's `chip` scope; `<ChipGroup>` spaces and wraps its chips and, with `selection="single"`, announces them as one choice. Never hand-roll a chip from `Pressable` + a pill radius.
|
|
2620
2648
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
2621
2649
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
2622
2650
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The single tab stop of a single-choice `<ChipGroup>`, for the web. The chips
|
|
2
|
+
// register with their group, and the group names the one that holds the tab
|
|
3
|
+
// stop: the picked chip, else the first enabled one. Native never runs this:
|
|
4
|
+
// React Native maps `tabIndex` to focusability, with no arrow keys to match.
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
function inDocumentOrder([, a], [, b]) {
|
|
9
|
+
const before = a.node.compareDocumentPosition(b.node) & 4;
|
|
10
|
+
return before ? -1 : 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tabStopHolder(members) {
|
|
14
|
+
const live = [...members.entries()]
|
|
15
|
+
.filter(([, m]) => !m.disabled && m.node && typeof m.node.compareDocumentPosition === "function")
|
|
16
|
+
.sort(inDocumentOrder);
|
|
17
|
+
const holder = live.find(([, m]) => m.selected) || live[0];
|
|
18
|
+
return holder ? holder[0] : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The group's half: a registry of its choices and the id that holds the tab
|
|
23
|
+
* stop. Inactive (both values null) unless `active`.
|
|
24
|
+
*
|
|
25
|
+
* @returns {{ register: Function|null, holder: string|null }}
|
|
26
|
+
*/
|
|
27
|
+
export function useTabStopRegistry(active) {
|
|
28
|
+
const members = React.useRef(new Map());
|
|
29
|
+
const [holder, setHolder] = React.useState(null);
|
|
30
|
+
const recompute = React.useCallback(() => setHolder(tabStopHolder(members.current)), []);
|
|
31
|
+
const register = React.useCallback(
|
|
32
|
+
(id, member) => {
|
|
33
|
+
if (member) members.current.set(id, member);
|
|
34
|
+
else members.current.delete(id);
|
|
35
|
+
recompute();
|
|
36
|
+
},
|
|
37
|
+
[recompute],
|
|
38
|
+
);
|
|
39
|
+
// After every render of the group: a reorder changes which chip is first.
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (active) recompute();
|
|
42
|
+
});
|
|
43
|
+
return active ? { register, holder } : { register: null, holder: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A choice's half: registers it and returns its `tabIndex`, or undefined when
|
|
48
|
+
* its group keeps no single tab stop. Until the group has answered, the picked
|
|
49
|
+
* chip holds the stop so the first paint already has one.
|
|
50
|
+
*/
|
|
51
|
+
export function useTabStop(group, { node, disabled, selected, enrolled }) {
|
|
52
|
+
const id = React.useId();
|
|
53
|
+
const register = enrolled && group ? group.register : null;
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (!register) return undefined;
|
|
56
|
+
register(id, { node: node.current, disabled: !!disabled, selected });
|
|
57
|
+
return () => register(id, null);
|
|
58
|
+
}, [register, id, node, disabled, selected]);
|
|
59
|
+
if (!register) return undefined;
|
|
60
|
+
if (disabled) return -1;
|
|
61
|
+
const holds = group.holder === null ? selected : group.holder === id;
|
|
62
|
+
return holds ? 0 : -1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The values `<Chip>` paints with, resolved from the theme's `chip` component
|
|
2
|
+
// scope. Platform-free, like tabs-tokens.js, so both hosts draw one chip 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 } from "./contract.js";
|
|
7
|
+
import { readableInk } from "./readable-ink.js";
|
|
8
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
9
|
+
|
|
10
|
+
export const CHIP_VARIANTS = Object.freeze(["filled", "outlined"]);
|
|
11
|
+
export const DEFAULT_CHIP_VARIANT = "filled";
|
|
12
|
+
|
|
13
|
+
// `md` is a full touch target; `sm` is the compact chip of a dense toolbar.
|
|
14
|
+
export const CHIP_SIZES = Object.freeze({
|
|
15
|
+
sm: Object.freeze({ minHeight: 28, fontSize: "xs", iconSize: 12 }),
|
|
16
|
+
md: Object.freeze({ minHeight: 44, fontSize: "sm", iconSize: 16 }),
|
|
17
|
+
});
|
|
18
|
+
export const DEFAULT_CHIP_SIZE = "md";
|
|
19
|
+
|
|
20
|
+
// How a group of chips announces: one choice, several, or plain actions.
|
|
21
|
+
export const CHIP_SELECTIONS = Object.freeze(["none", "single", "multiple"]);
|
|
22
|
+
|
|
23
|
+
// Per-chip overrides a widget may pass, named as the theme tokens are.
|
|
24
|
+
export const CHIP_TOKEN_NAMES = Object.freeze([
|
|
25
|
+
"background",
|
|
26
|
+
"textColor",
|
|
27
|
+
"borderColor",
|
|
28
|
+
"radius",
|
|
29
|
+
"selectedBackground",
|
|
30
|
+
"selectedTextColor",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function isSet(value) {
|
|
34
|
+
return value !== undefined && value !== null && value !== "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pick(overrides, scope, name) {
|
|
38
|
+
if (overrides && isSet(overrides[name])) return overrides[name];
|
|
39
|
+
if (scope && isSet(scope[name])) return scope[name];
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function chipVariant(variant) {
|
|
44
|
+
return CHIP_VARIANTS.includes(variant) ? variant : DEFAULT_CHIP_VARIANT;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Object.hasOwn, because an inherited key ("constructor") is truthy. */
|
|
48
|
+
export function chipSize(size) {
|
|
49
|
+
return CHIP_SIZES[Object.hasOwn(CHIP_SIZES, size) ? size : DEFAULT_CHIP_SIZE];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The role a chip announces with. A chip told whether it is `selected` is a
|
|
54
|
+
* choice: a radio in a single-choice group, otherwise a checkbox. Both carry
|
|
55
|
+
* `aria-checked`, the one selected state web and native each announce.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ selectable: boolean, pressable: boolean, selection?: string }} chip
|
|
58
|
+
* @returns {"radio"|"checkbox"|"button"|undefined}
|
|
59
|
+
*/
|
|
60
|
+
export function chipRole({ selectable, pressable, selection }) {
|
|
61
|
+
if (selectable) return selection === "single" ? "radio" : "checkbox";
|
|
62
|
+
return pressable ? "button" : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The role a group announces with; only a single-choice group has one. */
|
|
66
|
+
export function chipGroupRole(selection) {
|
|
67
|
+
return selection === "single" ? "radiogroup" : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve one chip: explicit override, then the theme's `chip` scope, then the
|
|
72
|
+
* variant's own defaults. Overrides pass the contract's bounds, so a bad prop
|
|
73
|
+
* cannot reach a native style unvalidated. A border is drawn only when a
|
|
74
|
+
* colour for it resolves, so an unthemed filled chip keeps its exact size.
|
|
75
|
+
* A fill chosen without its text colour gets the label that reads on it.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
78
|
+
* @param {object} [overrides] — CHIP_TOKEN_NAMES values, plus `variant`.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveChipTokens(theme, overrides) {
|
|
81
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
82
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
83
|
+
const radii = base.radii || DEFAULT_THEME_TOKENS.radii;
|
|
84
|
+
const scope = base.components && base.components.chip;
|
|
85
|
+
const own = normaliseThemeComponents({ chip: overrides }).chip;
|
|
86
|
+
const outlined = chipVariant(overrides && overrides.variant) === "outlined";
|
|
87
|
+
|
|
88
|
+
const ownFill = pick(own, scope, "background");
|
|
89
|
+
const ownSelectedFill = pick(own, scope, "selectedBackground");
|
|
90
|
+
const idleInk = outlined ? colors.onSurface : colors.onSurfaceMuted;
|
|
91
|
+
return {
|
|
92
|
+
background: ownFill ?? (outlined ? colors.surface : colors.surfaceMuted),
|
|
93
|
+
textColor:
|
|
94
|
+
pick(own, scope, "textColor") ?? (ownFill ? readableInk(ownFill, colors.surface) : idleInk),
|
|
95
|
+
borderColor: pick(own, scope, "borderColor") ?? (outlined ? colors.border : undefined),
|
|
96
|
+
radius: pick(own, scope, "radius") ?? radii.pill ?? DEFAULT_THEME_TOKENS.radii.pill,
|
|
97
|
+
selectedBackground: ownSelectedFill ?? colors.primary,
|
|
98
|
+
selectedTextColor:
|
|
99
|
+
pick(own, scope, "selectedTextColor") ??
|
|
100
|
+
(ownSelectedFill ? readableInk(ownSelectedFill, colors.surface) : colors.onPrimary),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The fill, ink and edge of one chip in its current state. */
|
|
105
|
+
export function chipPaint(tokens, selected) {
|
|
106
|
+
const paint = {
|
|
107
|
+
backgroundColor: selected ? tokens.selectedBackground : tokens.background,
|
|
108
|
+
color: selected ? tokens.selectedTextColor : tokens.textColor,
|
|
109
|
+
borderRadius: tokens.radius,
|
|
110
|
+
};
|
|
111
|
+
if (tokens.borderColor === undefined) return paint;
|
|
112
|
+
// A selected chip's edge is clear, so the fill beneath shows through it: a
|
|
113
|
+
// translucent fill painted again as the border would draw a darker ring.
|
|
114
|
+
return {
|
|
115
|
+
...paint,
|
|
116
|
+
borderWidth: 1,
|
|
117
|
+
borderColor: selected ? "transparent" : tokens.borderColor,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What each `chip` token resolves to while unset, keyed by token name, so the
|
|
123
|
+
* Studio can preview the default each control would replace. Each is resolved
|
|
124
|
+
* with only that token removed, since a text colour follows the fill beside
|
|
125
|
+
* it. No border colour: a chip with none set draws no border.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} theme — the resolved workspace theme.
|
|
128
|
+
*/
|
|
129
|
+
export function chipScopeDefaults(theme) {
|
|
130
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
131
|
+
const scope = (base.components && base.components.chip) || {};
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const name of CHIP_TOKEN_NAMES) {
|
|
134
|
+
const without = { ...scope, [name]: undefined };
|
|
135
|
+
const value = resolveChipTokens({ ...base, components: { ...base.components, chip: without } })[name];
|
|
136
|
+
if (value !== undefined) out[name] = value;
|
|
137
|
+
}
|
|
138
|
+
// A pill radius is past the token's range; its ceiling draws the same pill.
|
|
139
|
+
out.radius = Math.min(out.radius, CONTRACT.themeComponents.chip.tokens.radius.max);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Chip>` and `<ChipGroup>` SDK components.
|
|
2
|
+
// The platform bindings (`chip.js` / `chip.native.js`) differ only in where
|
|
3
|
+
// the primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Filter chips, tags and pills were hand-built per widget, each with its own
|
|
6
|
+
// radius, padding and selected colour. This one reads the theme's `chip` scope.
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useHostTheme } from "./hooks.js";
|
|
10
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
11
|
+
import { Icon } from "./icon.js";
|
|
12
|
+
import {
|
|
13
|
+
CHIP_SELECTIONS,
|
|
14
|
+
chipGroupRole,
|
|
15
|
+
chipPaint,
|
|
16
|
+
chipRole,
|
|
17
|
+
chipSize,
|
|
18
|
+
resolveChipTokens,
|
|
19
|
+
} from "./chip-tokens.js";
|
|
20
|
+
import { useTabStop, useTabStopRegistry } from "./chip-roving.js";
|
|
21
|
+
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
const DISABLED_OPACITY = 0.5;
|
|
24
|
+
// react-native-web ignores hitSlop, so the remove target has real width;
|
|
25
|
+
// native honours it, so there the target also reaches a full touch size.
|
|
26
|
+
const REMOVE_TARGET_MIN_WIDTH = 32;
|
|
27
|
+
const TOUCH_TARGET = 44;
|
|
28
|
+
const ARROW_STEP = Object.freeze({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 });
|
|
29
|
+
// Outside a group nothing says how close the next row is, so stay small.
|
|
30
|
+
const UNGROUPED_ROW_GAP = 4;
|
|
31
|
+
|
|
32
|
+
function plainLabel(label) {
|
|
33
|
+
if (typeof label === "number" && Number.isFinite(label)) return String(label);
|
|
34
|
+
return typeof label === "string" ? label : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shapeStyle({ dims, surface, grouped, disabled }) {
|
|
38
|
+
return {
|
|
39
|
+
flexDirection: "row",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
justifyContent: "center",
|
|
42
|
+
// A long label truncates inside its row instead of pushing past it.
|
|
43
|
+
flexShrink: 1,
|
|
44
|
+
minWidth: 0,
|
|
45
|
+
maxWidth: "100%",
|
|
46
|
+
minHeight: dims.minHeight,
|
|
47
|
+
...surface,
|
|
48
|
+
// Alone in a column it hugs its label; a group aligns its own chips.
|
|
49
|
+
...(grouped ? null : { alignSelf: "flex-start" }),
|
|
50
|
+
...(disabled ? { opacity: DISABLED_OPACITY } : null),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bodyStyle(spacing, removable) {
|
|
55
|
+
return {
|
|
56
|
+
flexDirection: "row",
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
justifyContent: "center",
|
|
59
|
+
gap: spacing.xs,
|
|
60
|
+
flexShrink: 1,
|
|
61
|
+
minWidth: 0,
|
|
62
|
+
paddingVertical: spacing.xs,
|
|
63
|
+
paddingStart: spacing.sm,
|
|
64
|
+
// The remove button owns the trailing inset when there is one.
|
|
65
|
+
paddingEnd: removable ? 0 : spacing.sm,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Read when a key is pressed, so the arrows follow the order on screen.
|
|
70
|
+
function enabledRadios(node) {
|
|
71
|
+
return Array.from(node.querySelectorAll('[role="radio"]')).filter(
|
|
72
|
+
(el) => el.getAttribute("aria-disabled") !== "true",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The radiogroup pattern: an arrow moves focus to the next choice and picks it.
|
|
77
|
+
function moveChoice(event, step) {
|
|
78
|
+
const group = event.currentTarget.closest('[role="radiogroup"]');
|
|
79
|
+
const radios = group ? enabledRadios(group) : [];
|
|
80
|
+
const at = radios.indexOf(event.currentTarget);
|
|
81
|
+
if (at < 0 || radios.length < 2) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
const next = radios[(at + step + radios.length) % radios.length];
|
|
84
|
+
next.focus();
|
|
85
|
+
next.click();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Web only; native fires no key events. react-native-web presses on Enter for
|
|
89
|
+
// every role but on Space for buttons alone, so a choice handles Space itself.
|
|
90
|
+
function choiceKeyHandler(onPress, roving) {
|
|
91
|
+
return (event) => {
|
|
92
|
+
// A held key must not toggle at repeat rate, nor Alt+Arrow lose its meaning.
|
|
93
|
+
if (event.repeat || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
94
|
+
if (event.key === " " || event.key === "Spacebar") {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
onPress();
|
|
97
|
+
} else if (roving && ARROW_STEP[event.key]) {
|
|
98
|
+
moveChoice(event, ARROW_STEP[event.key]);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
104
|
+
function pressProps({ onPress, disabled, role, name }) {
|
|
105
|
+
return {
|
|
106
|
+
onPress,
|
|
107
|
+
disabled: !!disabled,
|
|
108
|
+
accessibilityRole: role,
|
|
109
|
+
accessibilityLabel: name,
|
|
110
|
+
"aria-disabled": !!disabled,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function choiceProps({ onPress, disabled, selected, roving, tabIndex }) {
|
|
115
|
+
return {
|
|
116
|
+
"aria-checked": selected,
|
|
117
|
+
...(disabled ? null : { onKeyDown: choiceKeyHandler(onPress, roving) }),
|
|
118
|
+
...(tabIndex === undefined ? null : { tabIndex }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Never past half the gap to the next row: a tap there belongs to that row.
|
|
123
|
+
function removeHitSlop(dims, rowGap) {
|
|
124
|
+
const wanted = Math.max(0, (TOUCH_TARGET - dims.minHeight) / 2);
|
|
125
|
+
const vertical = Math.min(wanted, rowGap / 2);
|
|
126
|
+
return { top: vertical, bottom: vertical, right: (TOUCH_TARGET - REMOVE_TARGET_MIN_WIDTH) / 2 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// With no clip on the chip, each half rounds its own outer corners so a focus
|
|
130
|
+
// outline follows the pill.
|
|
131
|
+
function startCorners(radius) {
|
|
132
|
+
return { borderTopStartRadius: radius, borderBottomStartRadius: radius };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function endCorners(radius) {
|
|
136
|
+
return { borderTopEndRadius: radius, borderBottomEndRadius: radius };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function labelStyle({ typography, dims, selected, ink }) {
|
|
140
|
+
return {
|
|
141
|
+
flexShrink: 1,
|
|
142
|
+
fontFamily: typography.fontFamily,
|
|
143
|
+
fontSize: typography.sizes[dims.fontSize],
|
|
144
|
+
fontWeight: selected ? "600" : "500",
|
|
145
|
+
color: ink,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Bind the shared chip to one platform's primitives.
|
|
151
|
+
*
|
|
152
|
+
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
153
|
+
* @param {{ web?: boolean }} [host] — `web` turns on the single tab stop.
|
|
154
|
+
*/
|
|
155
|
+
export function makeChip(rn, host) {
|
|
156
|
+
const { View, Text, Pressable } = rn;
|
|
157
|
+
const rovingTabStop = !!(host && host.web);
|
|
158
|
+
const GroupContext = React.createContext(null);
|
|
159
|
+
|
|
160
|
+
function RemoveButton({ onRemove, disabled, label, spacing, dims, ink, radius, rowGap, testID }) {
|
|
161
|
+
return h(
|
|
162
|
+
Pressable,
|
|
163
|
+
{
|
|
164
|
+
...pressProps({ onPress: onRemove, disabled, role: "button", name: label }),
|
|
165
|
+
testID,
|
|
166
|
+
hitSlop: removeHitSlop(dims, rowGap),
|
|
167
|
+
style: {
|
|
168
|
+
...endCorners(radius),
|
|
169
|
+
alignSelf: "stretch",
|
|
170
|
+
alignItems: "center",
|
|
171
|
+
justifyContent: "center",
|
|
172
|
+
minWidth: REMOVE_TARGET_MIN_WIDTH,
|
|
173
|
+
paddingStart: spacing.xs,
|
|
174
|
+
paddingEnd: spacing.sm,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
h(Icon, { name: "x", size: dims.iconSize, color: ink }),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Chip({
|
|
182
|
+
label,
|
|
183
|
+
icon,
|
|
184
|
+
selected,
|
|
185
|
+
onPress,
|
|
186
|
+
onRemove,
|
|
187
|
+
disabled,
|
|
188
|
+
variant,
|
|
189
|
+
size,
|
|
190
|
+
numberOfLines = 1,
|
|
191
|
+
theme: explicitTheme,
|
|
192
|
+
accessibilityLabel,
|
|
193
|
+
removeAccessibilityLabel,
|
|
194
|
+
style,
|
|
195
|
+
testID,
|
|
196
|
+
...overrides
|
|
197
|
+
}) {
|
|
198
|
+
const group = React.useContext(GroupContext);
|
|
199
|
+
const hostTheme = useHostTheme();
|
|
200
|
+
// A Studio preview renders outside a widget context, so it passes its own.
|
|
201
|
+
const theme = explicitTheme || (group && group.theme) || hostTheme || DEFAULT_THEME_TOKENS;
|
|
202
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
203
|
+
const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
|
|
204
|
+
const dims = chipSize(size);
|
|
205
|
+
const isSelected = selected === true;
|
|
206
|
+
const tokens = resolveChipTokens(theme, { ...overrides, variant });
|
|
207
|
+
const { color: ink, ...surface } = chipPaint(tokens, isSelected);
|
|
208
|
+
const pressable = typeof onPress === "function";
|
|
209
|
+
const removable = typeof onRemove === "function";
|
|
210
|
+
const selection = group ? group.selection : "none";
|
|
211
|
+
// In a choice group every pressable chip is a choice, picked or not.
|
|
212
|
+
const selectable = pressable && (typeof selected === "boolean" || selection !== "none");
|
|
213
|
+
const roving = selectable && selection === "single";
|
|
214
|
+
const body = React.useRef(null);
|
|
215
|
+
const tabIndex = useTabStop(group, {
|
|
216
|
+
node: body,
|
|
217
|
+
disabled,
|
|
218
|
+
selected: isSelected,
|
|
219
|
+
enrolled: roving,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const shape = shapeStyle({ dims, surface, grouped: !!group, disabled });
|
|
223
|
+
const bodyLayout = bodyStyle(spacing, removable);
|
|
224
|
+
const content = [
|
|
225
|
+
icon ? h(Icon, { key: "icon", name: icon, size: dims.iconSize, color: ink }) : null,
|
|
226
|
+
h(
|
|
227
|
+
Text,
|
|
228
|
+
{
|
|
229
|
+
key: "label",
|
|
230
|
+
// Zero lifts the limit, so a long label wraps instead.
|
|
231
|
+
numberOfLines:
|
|
232
|
+
Number.isInteger(numberOfLines) && numberOfLines > 0 ? numberOfLines : undefined,
|
|
233
|
+
style: labelStyle({ typography, dims, selected: isSelected, ink }),
|
|
234
|
+
},
|
|
235
|
+
label,
|
|
236
|
+
),
|
|
237
|
+
];
|
|
238
|
+
const interaction = pressable
|
|
239
|
+
? {
|
|
240
|
+
...pressProps({
|
|
241
|
+
onPress,
|
|
242
|
+
disabled,
|
|
243
|
+
role: chipRole({ selectable, pressable, selection }),
|
|
244
|
+
name: accessibilityLabel || plainLabel(label) || undefined,
|
|
245
|
+
}),
|
|
246
|
+
...(selectable
|
|
247
|
+
? choiceProps({ onPress, disabled, selected: isSelected, roving, tabIndex })
|
|
248
|
+
: null),
|
|
249
|
+
ref: body,
|
|
250
|
+
}
|
|
251
|
+
: null;
|
|
252
|
+
const Body = pressable ? Pressable : View;
|
|
253
|
+
|
|
254
|
+
if (!removable) {
|
|
255
|
+
return h(Body, { ...interaction, style: [shape, bodyLayout, style], testID }, ...content);
|
|
256
|
+
}
|
|
257
|
+
return h(
|
|
258
|
+
View,
|
|
259
|
+
{ style: [shape, style], testID },
|
|
260
|
+
h(
|
|
261
|
+
Body,
|
|
262
|
+
{
|
|
263
|
+
...interaction,
|
|
264
|
+
style: [bodyLayout, startCorners(tokens.radius), { minHeight: dims.minHeight }],
|
|
265
|
+
testID: testID ? `${testID}-body` : undefined,
|
|
266
|
+
},
|
|
267
|
+
...content,
|
|
268
|
+
),
|
|
269
|
+
h(RemoveButton, {
|
|
270
|
+
onRemove,
|
|
271
|
+
disabled,
|
|
272
|
+
label: removeAccessibilityLabel || `Remove ${plainLabel(label)}`.trim(),
|
|
273
|
+
spacing,
|
|
274
|
+
dims,
|
|
275
|
+
ink,
|
|
276
|
+
radius: tokens.radius,
|
|
277
|
+
rowGap: group ? group.rowGap : UNGROUPED_ROW_GAP,
|
|
278
|
+
testID: testID ? `${testID}-remove` : undefined,
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
Chip.displayName = "Chip";
|
|
283
|
+
|
|
284
|
+
function ChipGroup({
|
|
285
|
+
children,
|
|
286
|
+
selection,
|
|
287
|
+
gap,
|
|
288
|
+
theme: explicitTheme,
|
|
289
|
+
accessibilityLabel,
|
|
290
|
+
style,
|
|
291
|
+
testID,
|
|
292
|
+
}) {
|
|
293
|
+
const hostTheme = useHostTheme();
|
|
294
|
+
const theme = explicitTheme || hostTheme || DEFAULT_THEME_TOKENS;
|
|
295
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
296
|
+
const mode = CHIP_SELECTIONS.includes(selection) ? selection : "none";
|
|
297
|
+
const rowGap = typeof gap === "number" && Number.isFinite(gap) && gap >= 0 ? gap : spacing.xs;
|
|
298
|
+
const { register, holder } = useTabStopRegistry(rovingTabStop && mode === "single");
|
|
299
|
+
const context = React.useMemo(
|
|
300
|
+
() => ({ selection: mode, theme: explicitTheme, rowGap, register, holder }),
|
|
301
|
+
[mode, explicitTheme, rowGap, register, holder],
|
|
302
|
+
);
|
|
303
|
+
return h(
|
|
304
|
+
GroupContext.Provider,
|
|
305
|
+
{ value: context },
|
|
306
|
+
h(
|
|
307
|
+
View,
|
|
308
|
+
{
|
|
309
|
+
accessibilityRole: chipGroupRole(mode),
|
|
310
|
+
accessibilityLabel,
|
|
311
|
+
testID,
|
|
312
|
+
style: [
|
|
313
|
+
{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: rowGap },
|
|
314
|
+
style,
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
children,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
ChipGroup.displayName = "ChipGroup";
|
|
322
|
+
|
|
323
|
+
return { Chip, ChipGroup };
|
|
324
|
+
}
|
package/dist/chip.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `<Chip>` / `<ChipGroup>` (web). Binds ./chip-view.js to react-native-web; see
|
|
2
|
+
// ./primitives.js for why not the bare specifier. Only the web keeps a single
|
|
3
|
+
// tab stop per single-choice group.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import { makeChip } from "./chip-view.js";
|
|
7
|
+
|
|
8
|
+
export const { Chip, ChipGroup } = makeChip(ReactNative, { web: true });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Chip>` / `<ChipGroup>` (native). Binds ./chip-view.js to React Native's own
|
|
2
|
+
// primitives; only the import differs from ./chip.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable } from "react-native";
|
|
5
|
+
import { makeChip } from "./chip-view.js";
|
|
6
|
+
|
|
7
|
+
export const { Chip, ChipGroup } = makeChip({ View, Text, Pressable });
|
package/dist/contract.cjs
CHANGED
|
@@ -745,6 +745,22 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
745
745
|
}),
|
|
746
746
|
targets: Object.freeze({}),
|
|
747
747
|
}),
|
|
748
|
+
// Filter chips, tags and pills: `<Chip>` reads this scope directly, so it
|
|
749
|
+
// names the SDK component it styles and binds no widget field. No uiDefault:
|
|
750
|
+
// an unset text colour follows its fill, so the default is derived.
|
|
751
|
+
chip: Object.freeze({
|
|
752
|
+
label: "Chips",
|
|
753
|
+
sdkComponents: Object.freeze(["Chip"]),
|
|
754
|
+
tokens: Object.freeze({
|
|
755
|
+
background: Object.freeze({ type: "color" }),
|
|
756
|
+
textColor: Object.freeze({ type: "color" }),
|
|
757
|
+
borderColor: Object.freeze({ type: "color" }),
|
|
758
|
+
radius: Object.freeze({ type: "size", min: 0, max: 48 }),
|
|
759
|
+
selectedBackground: Object.freeze({ type: "color" }),
|
|
760
|
+
selectedTextColor: Object.freeze({ type: "color" }),
|
|
761
|
+
}),
|
|
762
|
+
targets: Object.freeze({}),
|
|
763
|
+
}),
|
|
748
764
|
});
|
|
749
765
|
|
|
750
766
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -3073,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3073
3089
|
rnComponent: null,
|
|
3074
3090
|
docsUrl: null,
|
|
3075
3091
|
},
|
|
3092
|
+
{
|
|
3093
|
+
name: "Chip",
|
|
3094
|
+
description:
|
|
3095
|
+
'Themed chip. `<ChipGroup selection="single">{STATUSES.map((s) => <Chip key={s} label={s} selected={status === s} onPress={() => setStatus(s)} />)}</ChipGroup>`. THE way to build filter chips, tags and pills: anything small, rounded and tappable that selects, filters or can be removed. ONE facet with up to five options may be a single-choice `<ChipGroup>` of all its options; several facets, or a longer option list, go in the filter toolbar, where only the ACTIVE filters show as removable chips. Its fill, text colour, border, corner radius and selected colours come from the app\'s chip look (Theme Settings > Chips), so every chip in the app matches. Never hand-roll one from `Pressable` + `radii.pill` + a fill. A display-only STATE (Active, Paid, Overdue) is a `<Badge>`, not a chip. Props: `label` (a string or a number; it truncates on one line instead of breaking the row, or pass `numberOfLines={0}` to let it wrap), `icon` (optional lucide name before the label), `selected` (boolean; pass it together with `onPress` for a chip that toggles, and keep the selection in your own state), `onPress`, `onRemove` (adds a trailing x button, e.g. an active filter or a tag the user can drop; it is a separate button from `onPress`), `disabled`, `size` ("md" default, a full 44px touch target: use it on a narrow screen | "sm", a compact 28px chip for a medium or wide toolbar), `variant` ("filled" default | "outlined", a bordered chip on the surface), `accessibilityLabel`, `removeAccessibilityLabel` (default "Remove <label>"; pass your translated text), `style`, `testID`, and the overrides `background`, `textColor`, `borderColor`, `radius`, `selectedBackground`, `selectedTextColor` (hex colours; leave them unset so the app\'s chip look applies; a fill set without its text colour gets the dark or light label that reads on it). Inside a `<ChipGroup>` with a `selection`, every chip with `onPress` is a choice whether picked or not: a radio in a single-choice group, otherwise a checkbox, with its checked state; outside one, a chip given a boolean `selected` is a checkbox and a chip with only `onPress` is a button. On the web a single-choice group is one tab stop and the arrow keys move the choice.',
|
|
3096
|
+
rnComponent: null,
|
|
3097
|
+
docsUrl: null,
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
name: "ChipGroup",
|
|
3101
|
+
description:
|
|
3102
|
+
'Wrapping row of `<Chip>`s. `<ChipGroup selection="multiple" accessibilityLabel="Tags">…</ChipGroup>` spaces its chips and wraps them onto the next line on a narrow screen, so never lay chips out in a hand-rolled `flexWrap` row. Props: `selection` ("none" default | "single", one choice at a time, so its chips are radios | "multiple", any number of choices), `gap` (px between chips; defaults to the theme\'s `spacing.xs`), `accessibilityLabel`, `style`, `testID`. It only lays out and announces: the selected value lives in your state and each chip gets its own `selected` and `onPress`.',
|
|
3103
|
+
rnComponent: null,
|
|
3104
|
+
docsUrl: null,
|
|
3105
|
+
},
|
|
3076
3106
|
{
|
|
3077
3107
|
name: "Card",
|
|
3078
3108
|
description:
|
|
@@ -5671,7 +5701,13 @@ const CONTRACT = deepFreeze({
|
|
|
5671
5701
|
// seen online / today / in 7 or 30 days through the new
|
|
5672
5702
|
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5673
5703
|
// `sort: "newest"` for an admin's "who joined" list.
|
|
5674
|
-
|
|
5704
|
+
// 1.174.0: additive (sc-10926) — the `chip` component scope (background,
|
|
5705
|
+
// text colour, border colour, radius, selected background, selected text
|
|
5706
|
+
// colour) and the `Chip` / `ChipGroup` primitives that read it. The scope
|
|
5707
|
+
// binds no widget style fields: the component reads it on both hosts,
|
|
5708
|
+
// which a scope now states as `sdkComponents`.
|
|
5709
|
+
// Additive: an unthemed chip is the muted pill widgets drew by hand.
|
|
5710
|
+
version: "1.174.0",
|
|
5675
5711
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5676
5712
|
hooks: HOOKS,
|
|
5677
5713
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -745,6 +745,22 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
745
745
|
}),
|
|
746
746
|
targets: Object.freeze({}),
|
|
747
747
|
}),
|
|
748
|
+
// Filter chips, tags and pills: `<Chip>` reads this scope directly, so it
|
|
749
|
+
// names the SDK component it styles and binds no widget field. No uiDefault:
|
|
750
|
+
// an unset text colour follows its fill, so the default is derived.
|
|
751
|
+
chip: Object.freeze({
|
|
752
|
+
label: "Chips",
|
|
753
|
+
sdkComponents: Object.freeze(["Chip"]),
|
|
754
|
+
tokens: Object.freeze({
|
|
755
|
+
background: Object.freeze({ type: "color" }),
|
|
756
|
+
textColor: Object.freeze({ type: "color" }),
|
|
757
|
+
borderColor: Object.freeze({ type: "color" }),
|
|
758
|
+
radius: Object.freeze({ type: "size", min: 0, max: 48 }),
|
|
759
|
+
selectedBackground: Object.freeze({ type: "color" }),
|
|
760
|
+
selectedTextColor: Object.freeze({ type: "color" }),
|
|
761
|
+
}),
|
|
762
|
+
targets: Object.freeze({}),
|
|
763
|
+
}),
|
|
748
764
|
});
|
|
749
765
|
|
|
750
766
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -3073,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3073
3089
|
rnComponent: null,
|
|
3074
3090
|
docsUrl: null,
|
|
3075
3091
|
},
|
|
3092
|
+
{
|
|
3093
|
+
name: "Chip",
|
|
3094
|
+
description:
|
|
3095
|
+
'Themed chip. `<ChipGroup selection="single">{STATUSES.map((s) => <Chip key={s} label={s} selected={status === s} onPress={() => setStatus(s)} />)}</ChipGroup>`. THE way to build filter chips, tags and pills: anything small, rounded and tappable that selects, filters or can be removed. ONE facet with up to five options may be a single-choice `<ChipGroup>` of all its options; several facets, or a longer option list, go in the filter toolbar, where only the ACTIVE filters show as removable chips. Its fill, text colour, border, corner radius and selected colours come from the app\'s chip look (Theme Settings > Chips), so every chip in the app matches. Never hand-roll one from `Pressable` + `radii.pill` + a fill. A display-only STATE (Active, Paid, Overdue) is a `<Badge>`, not a chip. Props: `label` (a string or a number; it truncates on one line instead of breaking the row, or pass `numberOfLines={0}` to let it wrap), `icon` (optional lucide name before the label), `selected` (boolean; pass it together with `onPress` for a chip that toggles, and keep the selection in your own state), `onPress`, `onRemove` (adds a trailing x button, e.g. an active filter or a tag the user can drop; it is a separate button from `onPress`), `disabled`, `size` ("md" default, a full 44px touch target: use it on a narrow screen | "sm", a compact 28px chip for a medium or wide toolbar), `variant` ("filled" default | "outlined", a bordered chip on the surface), `accessibilityLabel`, `removeAccessibilityLabel` (default "Remove <label>"; pass your translated text), `style`, `testID`, and the overrides `background`, `textColor`, `borderColor`, `radius`, `selectedBackground`, `selectedTextColor` (hex colours; leave them unset so the app\'s chip look applies; a fill set without its text colour gets the dark or light label that reads on it). Inside a `<ChipGroup>` with a `selection`, every chip with `onPress` is a choice whether picked or not: a radio in a single-choice group, otherwise a checkbox, with its checked state; outside one, a chip given a boolean `selected` is a checkbox and a chip with only `onPress` is a button. On the web a single-choice group is one tab stop and the arrow keys move the choice.',
|
|
3096
|
+
rnComponent: null,
|
|
3097
|
+
docsUrl: null,
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
name: "ChipGroup",
|
|
3101
|
+
description:
|
|
3102
|
+
'Wrapping row of `<Chip>`s. `<ChipGroup selection="multiple" accessibilityLabel="Tags">…</ChipGroup>` spaces its chips and wraps them onto the next line on a narrow screen, so never lay chips out in a hand-rolled `flexWrap` row. Props: `selection` ("none" default | "single", one choice at a time, so its chips are radios | "multiple", any number of choices), `gap` (px between chips; defaults to the theme\'s `spacing.xs`), `accessibilityLabel`, `style`, `testID`. It only lays out and announces: the selected value lives in your state and each chip gets its own `selected` and `onPress`.',
|
|
3103
|
+
rnComponent: null,
|
|
3104
|
+
docsUrl: null,
|
|
3105
|
+
},
|
|
3076
3106
|
{
|
|
3077
3107
|
name: "Card",
|
|
3078
3108
|
description:
|
|
@@ -5667,7 +5697,13 @@ const CONTRACT = deepFreeze({
|
|
|
5667
5697
|
// seen online / today / in 7 or 30 days through the new
|
|
5668
5698
|
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5669
5699
|
// `sort: "newest"` for an admin's "who joined" list.
|
|
5670
|
-
|
|
5700
|
+
// 1.174.0: additive (sc-10926) — the `chip` component scope (background,
|
|
5701
|
+
// text colour, border colour, radius, selected background, selected text
|
|
5702
|
+
// colour) and the `Chip` / `ChipGroup` primitives that read it. The scope
|
|
5703
|
+
// binds no widget style fields: the component reads it on both hosts,
|
|
5704
|
+
// which a scope now states as `sdkComponents`.
|
|
5705
|
+
// Additive: an unthemed chip is the muted pill widgets drew by hand.
|
|
5706
|
+
version: "1.174.0",
|
|
5671
5707
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5672
5708
|
hooks: HOOKS,
|
|
5673
5709
|
primitives: PRIMITIVES,
|
package/dist/host.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
|
|
|
45
45
|
*/
|
|
46
46
|
export function bannerScopeDefaults(theme: unknown): Record<string, string | number>;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What each token of the `chip` component scope resolves to while unset,
|
|
50
|
+
* keyed by token name: the values `<Chip>` paints an unthemed app with.
|
|
51
|
+
*/
|
|
52
|
+
export function chipScopeDefaults(theme: unknown): Record<string, string | number>;
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* The theme a surface painting `backgroundColor` hands its subtree: surface
|
|
50
56
|
* tokens derived for that fill, and any type tier whose own ink cannot be read
|
|
@@ -391,6 +397,25 @@ export interface TopBarTokens {
|
|
|
391
397
|
*/
|
|
392
398
|
export function resolveTopBarTokens(theme: unknown): TopBarTokens;
|
|
393
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Whether a chrome surface reads as dark. Pass the colour the bar paints
|
|
402
|
+
* (`#RRGGBBAA` is composited over `backdrop`, else over the page in `theme`),
|
|
403
|
+
* or, for a bar that paints nothing, the colour of the text authored to sit on it.
|
|
404
|
+
*/
|
|
405
|
+
export function isDarkSurface(input?: {
|
|
406
|
+
surface?: string | null;
|
|
407
|
+
backdrop?: string | null;
|
|
408
|
+
textColor?: string | null;
|
|
409
|
+
theme?: unknown;
|
|
410
|
+
} | null): boolean;
|
|
411
|
+
|
|
412
|
+
/** The logo a surface draws: the dark-surface variant only beside an app logo. */
|
|
413
|
+
export function resolveAppLogo<T>(
|
|
414
|
+
logo: T | null | undefined,
|
|
415
|
+
darkSurfaceLogo: T | null | undefined,
|
|
416
|
+
onDark: boolean,
|
|
417
|
+
): T | null;
|
|
418
|
+
|
|
394
419
|
/** sc-8023 — the arrangeable contents of the sidebar layout's TOP STRIPE: the
|
|
395
420
|
* row across the top of the content, outside the panel and above it. */
|
|
396
421
|
export type TopStripeSlot = "back" | "menu" | "brand" | "notifications";
|
package/dist/host.js
CHANGED
|
@@ -51,6 +51,9 @@ export { surfaceAwareTheme } from "./surface-theme.js";
|
|
|
51
51
|
// the Expo export rendering the same notification (CLAUDE.md §8).
|
|
52
52
|
// The dialog's resolved look, for the Studio's Design > Components > Dialogs preview.
|
|
53
53
|
export { resolveModalTokens, modalScopeDefaults } from "./modal-tokens.js";
|
|
54
|
+
// What each `chip` scope token resolves to while unset, for the Studio's
|
|
55
|
+
// Design page to preview beside its controls.
|
|
56
|
+
export { chipScopeDefaults } from "./chip-tokens.js";
|
|
54
57
|
|
|
55
58
|
export {
|
|
56
59
|
createToastController,
|
|
@@ -107,6 +110,8 @@ export {
|
|
|
107
110
|
resolveSidebarUnfold,
|
|
108
111
|
resolveLocalNavModel,
|
|
109
112
|
drawsLocalNavAnywhere,
|
|
113
|
+
isDarkSurface,
|
|
114
|
+
resolveAppLogo,
|
|
110
115
|
} from "./navigation.js";
|
|
111
116
|
|
|
112
117
|
// sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
|
package/dist/index.d.ts
CHANGED
|
@@ -3917,6 +3917,73 @@ export const Banner: (props: {
|
|
|
3917
3917
|
testID?: string;
|
|
3918
3918
|
}) => any;
|
|
3919
3919
|
|
|
3920
|
+
export type ChipVariant = "filled" | "outlined";
|
|
3921
|
+
export type ChipSize = "sm" | "md";
|
|
3922
|
+
export type ChipSelection = "none" | "single" | "multiple";
|
|
3923
|
+
|
|
3924
|
+
/**
|
|
3925
|
+
* Themed filter chip, tag or pill. Its fill, text colour, border, corner radius
|
|
3926
|
+
* and selected colours come from the theme's `chip` scope, so every chip in the
|
|
3927
|
+
* app matches. Pass `selected` with `onPress` for a chip that toggles. Inside a
|
|
3928
|
+
* `<ChipGroup>` with a `selection` every pressable chip is a choice (a radio
|
|
3929
|
+
* when single, otherwise a checkbox); outside one, a boolean `selected` makes
|
|
3930
|
+
* it a checkbox. `onRemove` adds a trailing remove button.
|
|
3931
|
+
*
|
|
3932
|
+
* @example
|
|
3933
|
+
* <Chip label="Open" selected={status === "open"} onPress={() => setStatus("open")} />
|
|
3934
|
+
* <Chip label="Design" onRemove={() => removeTag("Design")} />
|
|
3935
|
+
*/
|
|
3936
|
+
export const Chip: (props: {
|
|
3937
|
+
label: string | number;
|
|
3938
|
+
/** Lines the label may take before it truncates; 0 lets it wrap. Default 1. */
|
|
3939
|
+
numberOfLines?: number;
|
|
3940
|
+
/** A lucide icon name, drawn before the label. */
|
|
3941
|
+
icon?: string;
|
|
3942
|
+
selected?: boolean;
|
|
3943
|
+
onPress?: () => void;
|
|
3944
|
+
onRemove?: () => void;
|
|
3945
|
+
disabled?: boolean;
|
|
3946
|
+
/** `md` (default) is a 44px touch target; `sm` is a compact 28px chip. */
|
|
3947
|
+
size?: ChipSize;
|
|
3948
|
+
variant?: ChipVariant;
|
|
3949
|
+
/**
|
|
3950
|
+
* Hex colour overrides; leave unset so the theme's chip look applies. A fill
|
|
3951
|
+
* set without its text colour gets the dark or light label that reads on it.
|
|
3952
|
+
*/
|
|
3953
|
+
background?: string;
|
|
3954
|
+
textColor?: string;
|
|
3955
|
+
borderColor?: string;
|
|
3956
|
+
radius?: number;
|
|
3957
|
+
selectedBackground?: string;
|
|
3958
|
+
selectedTextColor?: string;
|
|
3959
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
3960
|
+
theme?: any;
|
|
3961
|
+
accessibilityLabel?: string;
|
|
3962
|
+
/** Defaults to "Remove <label>"; pass translated text. */
|
|
3963
|
+
removeAccessibilityLabel?: string;
|
|
3964
|
+
style?: any;
|
|
3965
|
+
testID?: string;
|
|
3966
|
+
}) => any;
|
|
3967
|
+
|
|
3968
|
+
/**
|
|
3969
|
+
* Wrapping row of `<Chip>`s: spaces them and wraps onto the next line when the
|
|
3970
|
+
* row is full. `selection` sets how the chips announce and, on the web, how
|
|
3971
|
+
* the keyboard reaches them (a single-choice group is one tab stop whose
|
|
3972
|
+
* arrow keys move the choice). It holds no state: the selected
|
|
3973
|
+
* value stays in your own state.
|
|
3974
|
+
*/
|
|
3975
|
+
export const ChipGroup: (props: {
|
|
3976
|
+
children?: ReactNode;
|
|
3977
|
+
selection?: ChipSelection;
|
|
3978
|
+
/** Px between chips; defaults to the theme's `spacing.xs`. */
|
|
3979
|
+
gap?: number;
|
|
3980
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
3981
|
+
theme?: any;
|
|
3982
|
+
accessibilityLabel?: string;
|
|
3983
|
+
style?: any;
|
|
3984
|
+
testID?: string;
|
|
3985
|
+
}) => any;
|
|
3986
|
+
|
|
3920
3987
|
/**
|
|
3921
3988
|
* Themed collapsible sections. The surface comes from the theme's `card`
|
|
3922
3989
|
* scope and the headers and dividers from its `list` scope, so every
|
package/dist/index.js
CHANGED
|
@@ -285,6 +285,8 @@ export { Banner } from "./banner.js";
|
|
|
285
285
|
export { Modal } from "./modal.js";
|
|
286
286
|
// The standard confirmation, a preset of `<Modal>` and `<Button>`.
|
|
287
287
|
export { ConfirmDialog } from "./confirm-dialog.js";
|
|
288
|
+
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
289
|
+
export { Chip, ChipGroup } from "./chip.js";
|
|
288
290
|
// The semantic text tiers, shared with the built-in widgets.
|
|
289
291
|
export {
|
|
290
292
|
textRoleStyle,
|
package/dist/index.native.js
CHANGED
|
@@ -278,6 +278,8 @@ export { Banner } from "./banner.native.js";
|
|
|
278
278
|
export { Modal } from "./modal.native.js";
|
|
279
279
|
// The standard confirmation, a preset of `<Modal>` and `<Button>`.
|
|
280
280
|
export { ConfirmDialog } from "./confirm-dialog.native.js";
|
|
281
|
+
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
282
|
+
export { Chip, ChipGroup } from "./chip.native.js";
|
|
281
283
|
// The semantic text tiers, shared with the built-in widgets.
|
|
282
284
|
export {
|
|
283
285
|
textRoleStyle,
|
package/dist/navigation.cjs
CHANGED
|
@@ -535,6 +535,33 @@ function resolveTopStripeLayout(theme) {
|
|
|
535
535
|
};
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Whether a chrome surface reads as dark — asked for the app logo's variant
|
|
539
|
+
// and for the status-bar glyphs, so the two can never disagree about a bar.
|
|
540
|
+
// A see-through `surface` is judged by its blend with what is behind it: the
|
|
541
|
+
// given `backdrop`, else the page in `theme`. A bar that paints nothing passes
|
|
542
|
+
// `textColor` instead: light text was authored for a dark backdrop.
|
|
543
|
+
function isDarkSurface(input) {
|
|
544
|
+
const { surface, backdrop, textColor, theme } = isPlainObject(input) ? input : {};
|
|
545
|
+
if (isHexColor(surface)) {
|
|
546
|
+
const alpha = surface.length === 9 ? parseInt(surface.slice(7), 16) / 255 : 1;
|
|
547
|
+
const behind = isHexColor(backdrop)
|
|
548
|
+
? backdrop
|
|
549
|
+
: chromeSurface(isPlainObject(theme) ? theme : {});
|
|
550
|
+
const seen = alpha < 1 ? mixHex(behind, surface.slice(0, 7), alpha) : surface;
|
|
551
|
+
return readableTextColor(seen, "dark", "light") === "light";
|
|
552
|
+
}
|
|
553
|
+
if (isHexColor(textColor)) {
|
|
554
|
+
return readableTextColor(textColor, "dark", "light") === "dark";
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The dark-surface logo is a VARIANT of the app logo: it never stands alone.
|
|
560
|
+
function resolveAppLogo(logo, darkSurfaceLogo, onDark) {
|
|
561
|
+
if (!logo) return null;
|
|
562
|
+
return onDark && darkSurfaceLogo ? darkSurfaceLogo : logo;
|
|
563
|
+
}
|
|
564
|
+
|
|
538
565
|
const TAB_ALIGNMENTS = ["left", "right"];
|
|
539
566
|
const TAB_ROW_WIDTHS = ["full", "content"];
|
|
540
567
|
|
|
@@ -817,4 +844,4 @@ function borderWidthOr(...values) {
|
|
|
817
844
|
return BORDER_WIDTH_MIN;
|
|
818
845
|
}
|
|
819
846
|
|
|
820
|
-
module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopStripeLayout, resolveTopBarTokens, topStripeShowKey, TOP_STRIPE_ALWAYS_SHOWN_SLOTS, TOP_STRIPE_SLOTS, MIN_TOP_STRIPE_HEIGHT_PX, MAX_TOP_STRIPE_HEIGHT_PX, MIN_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_OFFSET_Y_PX, DEFAULT_TOP_STRIPE_HEIGHT_PX, DEFAULT_TOP_STRIPE_PADDING_X_PX, resolveSidebarUnfold, resolveLocalNavModel, drawsLocalNavAnywhere };
|
|
847
|
+
module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopStripeLayout, resolveTopBarTokens, topStripeShowKey, TOP_STRIPE_ALWAYS_SHOWN_SLOTS, TOP_STRIPE_SLOTS, MIN_TOP_STRIPE_HEIGHT_PX, MAX_TOP_STRIPE_HEIGHT_PX, MIN_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_OFFSET_Y_PX, DEFAULT_TOP_STRIPE_HEIGHT_PX, DEFAULT_TOP_STRIPE_PADDING_X_PX, resolveSidebarUnfold, resolveLocalNavModel, drawsLocalNavAnywhere, isDarkSurface, resolveAppLogo };
|
package/dist/navigation.js
CHANGED
|
@@ -526,6 +526,33 @@ export function resolveTopStripeLayout(theme) {
|
|
|
526
526
|
};
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// Whether a chrome surface reads as dark — asked for the app logo's variant
|
|
530
|
+
// and for the status-bar glyphs, so the two can never disagree about a bar.
|
|
531
|
+
// A see-through `surface` is judged by its blend with what is behind it: the
|
|
532
|
+
// given `backdrop`, else the page in `theme`. A bar that paints nothing passes
|
|
533
|
+
// `textColor` instead: light text was authored for a dark backdrop.
|
|
534
|
+
export function isDarkSurface(input) {
|
|
535
|
+
const { surface, backdrop, textColor, theme } = isPlainObject(input) ? input : {};
|
|
536
|
+
if (isHexColor(surface)) {
|
|
537
|
+
const alpha = surface.length === 9 ? parseInt(surface.slice(7), 16) / 255 : 1;
|
|
538
|
+
const behind = isHexColor(backdrop)
|
|
539
|
+
? backdrop
|
|
540
|
+
: chromeSurface(isPlainObject(theme) ? theme : {});
|
|
541
|
+
const seen = alpha < 1 ? mixHex(behind, surface.slice(0, 7), alpha) : surface;
|
|
542
|
+
return readableTextColor(seen, "dark", "light") === "light";
|
|
543
|
+
}
|
|
544
|
+
if (isHexColor(textColor)) {
|
|
545
|
+
return readableTextColor(textColor, "dark", "light") === "dark";
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// The dark-surface logo is a VARIANT of the app logo: it never stands alone.
|
|
551
|
+
export function resolveAppLogo(logo, darkSurfaceLogo, onDark) {
|
|
552
|
+
if (!logo) return null;
|
|
553
|
+
return onDark && darkSurfaceLogo ? darkSurfaceLogo : logo;
|
|
554
|
+
}
|
|
555
|
+
|
|
529
556
|
const TAB_ALIGNMENTS = ["left", "right"];
|
|
530
557
|
const TAB_ROW_WIDTHS = ["full", "content"];
|
|
531
558
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.218.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/contract.js
CHANGED
|
@@ -745,6 +745,22 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
745
745
|
}),
|
|
746
746
|
targets: Object.freeze({}),
|
|
747
747
|
}),
|
|
748
|
+
// Filter chips, tags and pills: `<Chip>` reads this scope directly, so it
|
|
749
|
+
// names the SDK component it styles and binds no widget field. No uiDefault:
|
|
750
|
+
// an unset text colour follows its fill, so the default is derived.
|
|
751
|
+
chip: Object.freeze({
|
|
752
|
+
label: "Chips",
|
|
753
|
+
sdkComponents: Object.freeze(["Chip"]),
|
|
754
|
+
tokens: Object.freeze({
|
|
755
|
+
background: Object.freeze({ type: "color" }),
|
|
756
|
+
textColor: Object.freeze({ type: "color" }),
|
|
757
|
+
borderColor: Object.freeze({ type: "color" }),
|
|
758
|
+
radius: Object.freeze({ type: "size", min: 0, max: 48 }),
|
|
759
|
+
selectedBackground: Object.freeze({ type: "color" }),
|
|
760
|
+
selectedTextColor: Object.freeze({ type: "color" }),
|
|
761
|
+
}),
|
|
762
|
+
targets: Object.freeze({}),
|
|
763
|
+
}),
|
|
748
764
|
});
|
|
749
765
|
|
|
750
766
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -3073,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3073
3089
|
rnComponent: null,
|
|
3074
3090
|
docsUrl: null,
|
|
3075
3091
|
},
|
|
3092
|
+
{
|
|
3093
|
+
name: "Chip",
|
|
3094
|
+
description:
|
|
3095
|
+
'Themed chip. `<ChipGroup selection="single">{STATUSES.map((s) => <Chip key={s} label={s} selected={status === s} onPress={() => setStatus(s)} />)}</ChipGroup>`. THE way to build filter chips, tags and pills: anything small, rounded and tappable that selects, filters or can be removed. ONE facet with up to five options may be a single-choice `<ChipGroup>` of all its options; several facets, or a longer option list, go in the filter toolbar, where only the ACTIVE filters show as removable chips. Its fill, text colour, border, corner radius and selected colours come from the app\'s chip look (Theme Settings > Chips), so every chip in the app matches. Never hand-roll one from `Pressable` + `radii.pill` + a fill. A display-only STATE (Active, Paid, Overdue) is a `<Badge>`, not a chip. Props: `label` (a string or a number; it truncates on one line instead of breaking the row, or pass `numberOfLines={0}` to let it wrap), `icon` (optional lucide name before the label), `selected` (boolean; pass it together with `onPress` for a chip that toggles, and keep the selection in your own state), `onPress`, `onRemove` (adds a trailing x button, e.g. an active filter or a tag the user can drop; it is a separate button from `onPress`), `disabled`, `size` ("md" default, a full 44px touch target: use it on a narrow screen | "sm", a compact 28px chip for a medium or wide toolbar), `variant` ("filled" default | "outlined", a bordered chip on the surface), `accessibilityLabel`, `removeAccessibilityLabel` (default "Remove <label>"; pass your translated text), `style`, `testID`, and the overrides `background`, `textColor`, `borderColor`, `radius`, `selectedBackground`, `selectedTextColor` (hex colours; leave them unset so the app\'s chip look applies; a fill set without its text colour gets the dark or light label that reads on it). Inside a `<ChipGroup>` with a `selection`, every chip with `onPress` is a choice whether picked or not: a radio in a single-choice group, otherwise a checkbox, with its checked state; outside one, a chip given a boolean `selected` is a checkbox and a chip with only `onPress` is a button. On the web a single-choice group is one tab stop and the arrow keys move the choice.',
|
|
3096
|
+
rnComponent: null,
|
|
3097
|
+
docsUrl: null,
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
name: "ChipGroup",
|
|
3101
|
+
description:
|
|
3102
|
+
'Wrapping row of `<Chip>`s. `<ChipGroup selection="multiple" accessibilityLabel="Tags">…</ChipGroup>` spaces its chips and wraps them onto the next line on a narrow screen, so never lay chips out in a hand-rolled `flexWrap` row. Props: `selection` ("none" default | "single", one choice at a time, so its chips are radios | "multiple", any number of choices), `gap` (px between chips; defaults to the theme\'s `spacing.xs`), `accessibilityLabel`, `style`, `testID`. It only lays out and announces: the selected value lives in your state and each chip gets its own `selected` and `onPress`.',
|
|
3103
|
+
rnComponent: null,
|
|
3104
|
+
docsUrl: null,
|
|
3105
|
+
},
|
|
3076
3106
|
{
|
|
3077
3107
|
name: "Card",
|
|
3078
3108
|
description:
|
|
@@ -5667,7 +5697,13 @@ const CONTRACT = deepFreeze({
|
|
|
5667
5697
|
// seen online / today / in 7 or 30 days through the new
|
|
5668
5698
|
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5669
5699
|
// `sort: "newest"` for an admin's "who joined" list.
|
|
5670
|
-
|
|
5700
|
+
// 1.174.0: additive (sc-10926) — the `chip` component scope (background,
|
|
5701
|
+
// text colour, border colour, radius, selected background, selected text
|
|
5702
|
+
// colour) and the `Chip` / `ChipGroup` primitives that read it. The scope
|
|
5703
|
+
// binds no widget style fields: the component reads it on both hosts,
|
|
5704
|
+
// which a scope now states as `sdkComponents`.
|
|
5705
|
+
// Additive: an unthemed chip is the muted pill widgets drew by hand.
|
|
5706
|
+
version: "1.174.0",
|
|
5671
5707
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5672
5708
|
hooks: HOOKS,
|
|
5673
5709
|
primitives: PRIMITIVES,
|