@colixsystems/widget-sdk 0.213.0 → 0.215.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 -2
- 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 +71 -4
- package/dist/contract.js +71 -4
- package/dist/host.d.ts +6 -0
- package/dist/host.js +3 -0
- package/dist/index.d.ts +90 -1
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/stats-hooks.js +22 -3
- package/package.json +1 -1
- package/src/contract.js +71 -4
package/README.md
CHANGED
|
@@ -83,7 +83,33 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.215.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.215.0 (contract 1.174.0)
|
|
89
|
+
|
|
90
|
+
**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.
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
const [status, setStatus] = useState("open");
|
|
94
|
+
<ChipGroup selection="single" accessibilityLabel="Status">
|
|
95
|
+
{["open", "done"].map((s) => (
|
|
96
|
+
<Chip key={s} label={s} selected={status === s} onPress={() => setStatus(s)} />
|
|
97
|
+
))}
|
|
98
|
+
</ChipGroup>
|
|
99
|
+
<Chip label="Design" icon="tag" onRemove={() => removeTag("Design")} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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>`.
|
|
103
|
+
|
|
104
|
+
A component scope can now name the SDK components that read it directly (`sdkComponents`) instead of binding widget style fields; `chip` is the first.
|
|
105
|
+
|
|
106
|
+
### What's new in 0.214.0 (contract 1.173.0)
|
|
107
|
+
|
|
108
|
+
**Who is behind a figure — `useActiveUsers({ within, group_id, limit, offset })`.** One page of the users seen within `within` — `"online"` (default; the app open in the last two minutes), `"day"` (since midnight in the workspace's time zone), `"week"` or `"month"` (the last 7 / 30 days) — most recently seen first, as `[{ id, name, last_active_at, last_platform }]` with `total` for paging. Same gate as `useUserActivity` (`users.activity:*` plus the "View user activity" permission); pass `null` to read nothing.
|
|
109
|
+
|
|
110
|
+
**"Who joined" — `useUsers({ joined_since, sort: "newest" })`.** The admin user list narrows to users created since `joined_since` (a `YYYY-MM-DD` date read in the workspace's time zone, or an ISO instant) and lists them newest first. Join dates are admin data, so a caller with only the directory view gets the plain list.
|
|
111
|
+
|
|
112
|
+
**Reminding several — `remind(id, message, { refetch: false })`.** `useInactiveUsers`' `remind` re-reads the page after each reminder; pass `{ refetch: false }` while sending a batch and call `refetch()` once at the end.
|
|
87
113
|
|
|
88
114
|
### What's new in 0.213.0 (contract 1.172.0)
|
|
89
115
|
|
|
@@ -246,7 +272,7 @@ Like the `loading` scope, `alert` styles an SDK component rather than a widget's
|
|
|
246
272
|
|
|
247
273
|
**An app's user totals for its own admins — `useUserStats({ days, group_id })`.** Active and deactivated users, new users per day (7 / 30 days) or week (90 days) with the previous period's total beside them, users in no group, empty groups, and invite figures. Totals only — never a list of people. Needs `users.read:*` and the viewer's admin user access (else `error.code === "FORBIDDEN"`); `stats.invites` is null unless the widget also declares `users.write:*` and the viewer holds it.
|
|
248
274
|
|
|
249
|
-
**User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`). Days follow the workspace's time zone (`period.time_zone`).
|
|
275
|
+
**User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`); pass `{ refetch: false }` as a third argument when reminding several, then call `refetch()` once. Days follow the workspace's time zone (`period.time_zone`).
|
|
250
276
|
|
|
251
277
|
**Linter fix.** A widget that uses `useGroups` or `useUsers` is no longer refused for a write it never makes: `StyleSheet.create(...)` and an event subscription's bare `remove()` (an `AppState` listener, say) are not user or group writes.
|
|
252
278
|
|
|
@@ -2609,6 +2635,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2609
2635
|
- `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.
|
|
2610
2636
|
- `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`.
|
|
2611
2637
|
- `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.
|
|
2638
|
+
- `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.
|
|
2612
2639
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
2613
2640
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
2614
2641
|
|
|
@@ -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
|
|
@@ -1960,7 +1976,9 @@ const HOOKS = [
|
|
|
1960
1976
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1961
1977
|
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1962
1978
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1963
|
-
"res.data; rows are snake_case (is_active, …).
|
|
1979
|
+
"res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
|
|
1980
|
+
"query { joined_since, sort: \"newest\" } lists who joined since a date " +
|
|
1981
|
+
"(read in the workspace's time zone), newest first. Reads need users.read:* " +
|
|
1964
1982
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1965
1983
|
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1966
1984
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
@@ -2082,6 +2100,30 @@ const HOOKS = [
|
|
|
2082
2100
|
requiredContextSlice: ["directory.users"],
|
|
2083
2101
|
scopes: ["users.activity:*"],
|
|
2084
2102
|
},
|
|
2103
|
+
// sc-11066 — who was seen in a window, behind users.activity.
|
|
2104
|
+
{
|
|
2105
|
+
name: "useActiveUsers",
|
|
2106
|
+
signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
|
|
2107
|
+
optionalArgs: ["within", "group_id", "limit", "offset"],
|
|
2108
|
+
description:
|
|
2109
|
+
"One page of users seen within `within`: \"online\" (default; the app " +
|
|
2110
|
+
"open in the last two minutes), \"day\" (since midnight in the " +
|
|
2111
|
+
"workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
|
|
2112
|
+
"most recently seen first: `users` = [{ id, name, last_active_at, " +
|
|
2113
|
+
"last_platform }], `total` for paging. Use it to show WHO is behind an " +
|
|
2114
|
+
"online or active figure. Same gate and errors as useUserActivity; null " +
|
|
2115
|
+
"reads nothing.",
|
|
2116
|
+
returnShape: {
|
|
2117
|
+
users: "Array<{ id, name, last_active_at, last_platform }>",
|
|
2118
|
+
total: "number | null",
|
|
2119
|
+
loading: "boolean",
|
|
2120
|
+
fetching: "boolean",
|
|
2121
|
+
error: "DirectoryError | null",
|
|
2122
|
+
refetch: "() => Promise<void>",
|
|
2123
|
+
},
|
|
2124
|
+
requiredContextSlice: ["directory.users"],
|
|
2125
|
+
scopes: ["users.activity:*"],
|
|
2126
|
+
},
|
|
2085
2127
|
{
|
|
2086
2128
|
name: "useInactiveUsers",
|
|
2087
2129
|
signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
|
|
@@ -2091,7 +2133,8 @@ const HOOKS = [
|
|
|
2091
2133
|
"never-seen first, then least recently seen: `users` = [{ id, name, " +
|
|
2092
2134
|
"last_active_at, last_reminded_at }] (last_active_at null = never seen " +
|
|
2093
2135
|
"since recording began), `total` for paging. `remind(id, { title, body })` " +
|
|
2094
|
-
"notifies one of them, records when (last_reminded_at) and re-reads
|
|
2136
|
+
"notifies one of them, records when (last_reminded_at) and re-reads; " +
|
|
2137
|
+
"pass `{ refetch: false }` third to skip the re-read in a batch. " +
|
|
2095
2138
|
"Same gate and errors as useUserActivity; null reads nothing.",
|
|
2096
2139
|
returnShape: {
|
|
2097
2140
|
users: "Array<{ id, name, last_active_at, last_reminded_at }>",
|
|
@@ -2100,7 +2143,7 @@ const HOOKS = [
|
|
|
2100
2143
|
fetching: "boolean",
|
|
2101
2144
|
error: "DirectoryError | null",
|
|
2102
2145
|
refetch: "() => Promise<void>",
|
|
2103
|
-
remind: "(id, { title, body }) => Promise<void>",
|
|
2146
|
+
remind: "(id, { title, body }, { refetch? }) => Promise<void>",
|
|
2104
2147
|
},
|
|
2105
2148
|
requiredContextSlice: ["directory.users"],
|
|
2106
2149
|
scopes: ["users.activity:*"],
|
|
@@ -3046,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3046
3089
|
rnComponent: null,
|
|
3047
3090
|
docsUrl: null,
|
|
3048
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
|
+
},
|
|
3049
3106
|
{
|
|
3050
3107
|
name: "Card",
|
|
3051
3108
|
description:
|
|
@@ -5640,7 +5697,17 @@ const CONTRACT = deepFreeze({
|
|
|
5640
5697
|
// and cancel labels, `tone` "default" | "destructive", `busy`). No new
|
|
5641
5698
|
// theme scope: the panel reads `modal`, the actions `button` and
|
|
5642
5699
|
// `destructive`. `Button` forwards a ref to its pressable.
|
|
5643
|
-
|
|
5700
|
+
// 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
|
|
5701
|
+
// seen online / today / in 7 or 30 days through the new
|
|
5702
|
+
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5703
|
+
// `sort: "newest"` for an admin's "who joined" list.
|
|
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",
|
|
5644
5711
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5645
5712
|
hooks: HOOKS,
|
|
5646
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
|
|
@@ -1960,7 +1976,9 @@ const HOOKS = [
|
|
|
1960
1976
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1961
1977
|
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1962
1978
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1963
|
-
"res.data; rows are snake_case (is_active, …).
|
|
1979
|
+
"res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
|
|
1980
|
+
"query { joined_since, sort: \"newest\" } lists who joined since a date " +
|
|
1981
|
+
"(read in the workspace's time zone), newest first. Reads need users.read:* " +
|
|
1964
1982
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1965
1983
|
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1966
1984
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
@@ -2082,6 +2100,30 @@ const HOOKS = [
|
|
|
2082
2100
|
requiredContextSlice: ["directory.users"],
|
|
2083
2101
|
scopes: ["users.activity:*"],
|
|
2084
2102
|
},
|
|
2103
|
+
// sc-11066 — who was seen in a window, behind users.activity.
|
|
2104
|
+
{
|
|
2105
|
+
name: "useActiveUsers",
|
|
2106
|
+
signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
|
|
2107
|
+
optionalArgs: ["within", "group_id", "limit", "offset"],
|
|
2108
|
+
description:
|
|
2109
|
+
"One page of users seen within `within`: \"online\" (default; the app " +
|
|
2110
|
+
"open in the last two minutes), \"day\" (since midnight in the " +
|
|
2111
|
+
"workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
|
|
2112
|
+
"most recently seen first: `users` = [{ id, name, last_active_at, " +
|
|
2113
|
+
"last_platform }], `total` for paging. Use it to show WHO is behind an " +
|
|
2114
|
+
"online or active figure. Same gate and errors as useUserActivity; null " +
|
|
2115
|
+
"reads nothing.",
|
|
2116
|
+
returnShape: {
|
|
2117
|
+
users: "Array<{ id, name, last_active_at, last_platform }>",
|
|
2118
|
+
total: "number | null",
|
|
2119
|
+
loading: "boolean",
|
|
2120
|
+
fetching: "boolean",
|
|
2121
|
+
error: "DirectoryError | null",
|
|
2122
|
+
refetch: "() => Promise<void>",
|
|
2123
|
+
},
|
|
2124
|
+
requiredContextSlice: ["directory.users"],
|
|
2125
|
+
scopes: ["users.activity:*"],
|
|
2126
|
+
},
|
|
2085
2127
|
{
|
|
2086
2128
|
name: "useInactiveUsers",
|
|
2087
2129
|
signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
|
|
@@ -2091,7 +2133,8 @@ const HOOKS = [
|
|
|
2091
2133
|
"never-seen first, then least recently seen: `users` = [{ id, name, " +
|
|
2092
2134
|
"last_active_at, last_reminded_at }] (last_active_at null = never seen " +
|
|
2093
2135
|
"since recording began), `total` for paging. `remind(id, { title, body })` " +
|
|
2094
|
-
"notifies one of them, records when (last_reminded_at) and re-reads
|
|
2136
|
+
"notifies one of them, records when (last_reminded_at) and re-reads; " +
|
|
2137
|
+
"pass `{ refetch: false }` third to skip the re-read in a batch. " +
|
|
2095
2138
|
"Same gate and errors as useUserActivity; null reads nothing.",
|
|
2096
2139
|
returnShape: {
|
|
2097
2140
|
users: "Array<{ id, name, last_active_at, last_reminded_at }>",
|
|
@@ -2100,7 +2143,7 @@ const HOOKS = [
|
|
|
2100
2143
|
fetching: "boolean",
|
|
2101
2144
|
error: "DirectoryError | null",
|
|
2102
2145
|
refetch: "() => Promise<void>",
|
|
2103
|
-
remind: "(id, { title, body }) => Promise<void>",
|
|
2146
|
+
remind: "(id, { title, body }, { refetch? }) => Promise<void>",
|
|
2104
2147
|
},
|
|
2105
2148
|
requiredContextSlice: ["directory.users"],
|
|
2106
2149
|
scopes: ["users.activity:*"],
|
|
@@ -3046,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3046
3089
|
rnComponent: null,
|
|
3047
3090
|
docsUrl: null,
|
|
3048
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
|
+
},
|
|
3049
3106
|
{
|
|
3050
3107
|
name: "Card",
|
|
3051
3108
|
description:
|
|
@@ -5636,7 +5693,17 @@ const CONTRACT = deepFreeze({
|
|
|
5636
5693
|
// and cancel labels, `tone` "default" | "destructive", `busy`). No new
|
|
5637
5694
|
// theme scope: the panel reads `modal`, the actions `button` and
|
|
5638
5695
|
// `destructive`. `Button` forwards a ref to its pressable.
|
|
5639
|
-
|
|
5696
|
+
// 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
|
|
5697
|
+
// seen online / today / in 7 or 30 days through the new
|
|
5698
|
+
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5699
|
+
// `sort: "newest"` for an admin's "who joined" list.
|
|
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",
|
|
5640
5707
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5641
5708
|
hooks: HOOKS,
|
|
5642
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
|
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,
|
package/dist/index.d.ts
CHANGED
|
@@ -1107,6 +1107,21 @@ export function useUserActivity(query?: { days?: 7 | 30 | 90; group_id?: string
|
|
|
1107
1107
|
refetch(): Promise<void>;
|
|
1108
1108
|
};
|
|
1109
1109
|
|
|
1110
|
+
/** Users seen within `within` (default "online"), most recently seen first. */
|
|
1111
|
+
export function useActiveUsers(query?: {
|
|
1112
|
+
within?: "online" | "day" | "week" | "month";
|
|
1113
|
+
group_id?: string;
|
|
1114
|
+
limit?: number;
|
|
1115
|
+
offset?: number;
|
|
1116
|
+
} | null): {
|
|
1117
|
+
users: Array<{ id: string; name: string; last_active_at: string; last_platform: "web" | "ios" | "android" | "unknown" }>;
|
|
1118
|
+
total: number | null;
|
|
1119
|
+
loading: boolean;
|
|
1120
|
+
fetching: boolean;
|
|
1121
|
+
error: Error | null;
|
|
1122
|
+
refetch(): Promise<void>;
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1110
1125
|
/** Users not seen for `inactive_days` (default 30), never-seen first. */
|
|
1111
1126
|
export function useInactiveUsers(query?: {
|
|
1112
1127
|
inactive_days?: number;
|
|
@@ -1121,7 +1136,8 @@ export function useInactiveUsers(query?: {
|
|
|
1121
1136
|
error: Error | null;
|
|
1122
1137
|
refetch(): Promise<void>;
|
|
1123
1138
|
/** Notify one user, record when, then re-read the page. */
|
|
1124
|
-
|
|
1139
|
+
/** `{ refetch: false }` skips re-reading the page, for a batch that reads once. */
|
|
1140
|
+
remind(id: string, message: { title: string; body: string }, options?: { refetch?: boolean }): Promise<void>;
|
|
1125
1141
|
};
|
|
1126
1142
|
|
|
1127
1143
|
/** One record's online members, as `useOnlineMembers` reports them. */
|
|
@@ -2743,6 +2759,12 @@ export interface UsersQuery {
|
|
|
2743
2759
|
is_active?: boolean;
|
|
2744
2760
|
/** Restrict to members of one app-user group — the `id` of a `useGroups()` row. */
|
|
2745
2761
|
group_id?: string;
|
|
2762
|
+
/** Admin view only: users created since this date (workspace time zone) or instant. */
|
|
2763
|
+
joined_since?: string;
|
|
2764
|
+
/** Admin view only: "newest" lists the most recently created first. */
|
|
2765
|
+
sort?: "name" | "newest";
|
|
2766
|
+
/** "real" leaves out preview and store-review accounts; an in-app admin always gets "real". */
|
|
2767
|
+
audience?: "all" | "real" | "preview";
|
|
2746
2768
|
limit?: number;
|
|
2747
2769
|
offset?: number;
|
|
2748
2770
|
}
|
|
@@ -3895,6 +3917,73 @@ export const Banner: (props: {
|
|
|
3895
3917
|
testID?: string;
|
|
3896
3918
|
}) => any;
|
|
3897
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
|
+
|
|
3898
3987
|
/**
|
|
3899
3988
|
* Themed collapsible sections. The surface comes from the theme's `card`
|
|
3900
3989
|
* scope and the headers and dividers from its `list` scope, so every
|
package/dist/index.js
CHANGED
|
@@ -127,6 +127,7 @@ export { useDatastoreFigure } from "./datastore-figure.js";
|
|
|
127
127
|
export {
|
|
128
128
|
useUserStats,
|
|
129
129
|
useUserActivity,
|
|
130
|
+
useActiveUsers,
|
|
130
131
|
useInactiveUsers,
|
|
131
132
|
} from "./stats-hooks.js";
|
|
132
133
|
// sc-11025 — one source, bound to how each platform says the app is in front.
|
|
@@ -284,6 +285,8 @@ export { Banner } from "./banner.js";
|
|
|
284
285
|
export { Modal } from "./modal.js";
|
|
285
286
|
// The standard confirmation, a preset of `<Modal>` and `<Button>`.
|
|
286
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";
|
|
287
290
|
// The semantic text tiers, shared with the built-in widgets.
|
|
288
291
|
export {
|
|
289
292
|
textRoleStyle,
|
package/dist/index.native.js
CHANGED
|
@@ -125,6 +125,7 @@ export { useDatastoreFigure } from "./datastore-figure.js";
|
|
|
125
125
|
export {
|
|
126
126
|
useUserStats,
|
|
127
127
|
useUserActivity,
|
|
128
|
+
useActiveUsers,
|
|
128
129
|
useInactiveUsers,
|
|
129
130
|
} from "./stats-hooks.js";
|
|
130
131
|
// sc-11025 — see the note on the web mirror in ./index.js.
|
|
@@ -277,6 +278,8 @@ export { Banner } from "./banner.native.js";
|
|
|
277
278
|
export { Modal } from "./modal.native.js";
|
|
278
279
|
// The standard confirmation, a preset of `<Modal>` and `<Button>`.
|
|
279
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";
|
|
280
283
|
// The semantic text tiers, shared with the built-in widgets.
|
|
281
284
|
export {
|
|
282
285
|
textRoleStyle,
|
package/dist/stats-hooks.js
CHANGED
|
@@ -108,13 +108,32 @@ export function useUserActivity(query) {
|
|
|
108
108
|
return { ...state, activity: data };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* sc-11066 — one page of users seen within `within` ("online" — the last two
|
|
113
|
+
* minutes — "day", "week" or "month"), most recently seen first. `query`:
|
|
114
|
+
* `{ within?, group_id?, limit?, offset? }`. Returns `{ users, total, loading,
|
|
115
|
+
* fetching, error, refetch }`; each user is `{ id, name, last_active_at,
|
|
116
|
+
* last_platform }`. Same gate as useUserActivity; null reads nothing.
|
|
117
|
+
*/
|
|
118
|
+
export function useActiveUsers(query) {
|
|
119
|
+
const users = useUsersNamespace("useActiveUsers", "active");
|
|
120
|
+
const key = query === null ? null : serialize(query);
|
|
121
|
+
const { data, ...state } = useKeyedRead(() => users.active(query), "active-users", key, toDirectoryError);
|
|
122
|
+
return {
|
|
123
|
+
...state,
|
|
124
|
+
users: data && Array.isArray(data.data) ? data.data : [],
|
|
125
|
+
total: data && data.meta && Number.isFinite(data.meta.total) ? data.meta.total : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
/**
|
|
112
130
|
* sc-10691 — one page of users not seen for `inactive_days` (default 30),
|
|
113
131
|
* never-seen first. `query`: `{ inactive_days?, group_id?, limit?, offset? }`.
|
|
114
132
|
* Returns `{ users, total, loading, fetching, error, refetch, remind }`; each
|
|
115
133
|
* user is `{ id, name, last_active_at, last_reminded_at }` (last_active_at
|
|
116
134
|
* null when never seen). `remind(id, { title, body })` notifies one and
|
|
117
|
-
* re-reads the page
|
|
135
|
+
* re-reads the page; `{ refetch: false }` as a third argument skips the
|
|
136
|
+
* re-read, so a batch reads once at the end. Same gate as useUserActivity.
|
|
118
137
|
*/
|
|
119
138
|
export function useInactiveUsers(query) {
|
|
120
139
|
const users = useUsersNamespace("useInactiveUsers", "inactive");
|
|
@@ -122,7 +141,7 @@ export function useInactiveUsers(query) {
|
|
|
122
141
|
const { data, ...state } = useKeyedRead(() => users.inactive(query), "inactive", key, toDirectoryError);
|
|
123
142
|
const { refetch } = state;
|
|
124
143
|
const remind = useCallback(
|
|
125
|
-
async (id, message) => {
|
|
144
|
+
async (id, message, { refetch: reread = true } = {}) => {
|
|
126
145
|
if (typeof users.remind !== "function") {
|
|
127
146
|
throw new Error("useInactiveUsers: host did not inject ctx.directory.users.remind");
|
|
128
147
|
}
|
|
@@ -131,7 +150,7 @@ export function useInactiveUsers(query) {
|
|
|
131
150
|
} catch (err) {
|
|
132
151
|
throw toDirectoryError(err);
|
|
133
152
|
}
|
|
134
|
-
await refetch();
|
|
153
|
+
if (reread) await refetch();
|
|
135
154
|
},
|
|
136
155
|
[users, refetch],
|
|
137
156
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.215.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
|
|
@@ -1960,7 +1976,9 @@ const HOOKS = [
|
|
|
1960
1976
|
"Returns { users, loading, error, refetch, invite, deactivate, " +
|
|
1961
1977
|
"reactivate, remove, sendPasswordReset, update }. " +
|
|
1962
1978
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
1963
|
-
"res.data; rows are snake_case (is_active, …).
|
|
1979
|
+
"res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
|
|
1980
|
+
"query { joined_since, sort: \"newest\" } lists who joined since a date " +
|
|
1981
|
+
"(read in the workspace's time zone), newest first. Reads need users.read:* " +
|
|
1964
1982
|
"scope; edit-style mutations (invite/deactivate/reactivate/" +
|
|
1965
1983
|
"sendPasswordReset/update) need users.write:*, and the destructive remove() " +
|
|
1966
1984
|
"additionally needs users.delete:* (SC-902). The `invite` call accepts " +
|
|
@@ -2082,6 +2100,30 @@ const HOOKS = [
|
|
|
2082
2100
|
requiredContextSlice: ["directory.users"],
|
|
2083
2101
|
scopes: ["users.activity:*"],
|
|
2084
2102
|
},
|
|
2103
|
+
// sc-11066 — who was seen in a window, behind users.activity.
|
|
2104
|
+
{
|
|
2105
|
+
name: "useActiveUsers",
|
|
2106
|
+
signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
|
|
2107
|
+
optionalArgs: ["within", "group_id", "limit", "offset"],
|
|
2108
|
+
description:
|
|
2109
|
+
"One page of users seen within `within`: \"online\" (default; the app " +
|
|
2110
|
+
"open in the last two minutes), \"day\" (since midnight in the " +
|
|
2111
|
+
"workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
|
|
2112
|
+
"most recently seen first: `users` = [{ id, name, last_active_at, " +
|
|
2113
|
+
"last_platform }], `total` for paging. Use it to show WHO is behind an " +
|
|
2114
|
+
"online or active figure. Same gate and errors as useUserActivity; null " +
|
|
2115
|
+
"reads nothing.",
|
|
2116
|
+
returnShape: {
|
|
2117
|
+
users: "Array<{ id, name, last_active_at, last_platform }>",
|
|
2118
|
+
total: "number | null",
|
|
2119
|
+
loading: "boolean",
|
|
2120
|
+
fetching: "boolean",
|
|
2121
|
+
error: "DirectoryError | null",
|
|
2122
|
+
refetch: "() => Promise<void>",
|
|
2123
|
+
},
|
|
2124
|
+
requiredContextSlice: ["directory.users"],
|
|
2125
|
+
scopes: ["users.activity:*"],
|
|
2126
|
+
},
|
|
2085
2127
|
{
|
|
2086
2128
|
name: "useInactiveUsers",
|
|
2087
2129
|
signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
|
|
@@ -2091,7 +2133,8 @@ const HOOKS = [
|
|
|
2091
2133
|
"never-seen first, then least recently seen: `users` = [{ id, name, " +
|
|
2092
2134
|
"last_active_at, last_reminded_at }] (last_active_at null = never seen " +
|
|
2093
2135
|
"since recording began), `total` for paging. `remind(id, { title, body })` " +
|
|
2094
|
-
"notifies one of them, records when (last_reminded_at) and re-reads
|
|
2136
|
+
"notifies one of them, records when (last_reminded_at) and re-reads; " +
|
|
2137
|
+
"pass `{ refetch: false }` third to skip the re-read in a batch. " +
|
|
2095
2138
|
"Same gate and errors as useUserActivity; null reads nothing.",
|
|
2096
2139
|
returnShape: {
|
|
2097
2140
|
users: "Array<{ id, name, last_active_at, last_reminded_at }>",
|
|
@@ -2100,7 +2143,7 @@ const HOOKS = [
|
|
|
2100
2143
|
fetching: "boolean",
|
|
2101
2144
|
error: "DirectoryError | null",
|
|
2102
2145
|
refetch: "() => Promise<void>",
|
|
2103
|
-
remind: "(id, { title, body }) => Promise<void>",
|
|
2146
|
+
remind: "(id, { title, body }, { refetch? }) => Promise<void>",
|
|
2104
2147
|
},
|
|
2105
2148
|
requiredContextSlice: ["directory.users"],
|
|
2106
2149
|
scopes: ["users.activity:*"],
|
|
@@ -3046,6 +3089,20 @@ const PRIMITIVES = [
|
|
|
3046
3089
|
rnComponent: null,
|
|
3047
3090
|
docsUrl: null,
|
|
3048
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
|
+
},
|
|
3049
3106
|
{
|
|
3050
3107
|
name: "Card",
|
|
3051
3108
|
description:
|
|
@@ -5636,7 +5693,17 @@ const CONTRACT = deepFreeze({
|
|
|
5636
5693
|
// and cancel labels, `tone` "default" | "destructive", `busy`). No new
|
|
5637
5694
|
// theme scope: the panel reads `modal`, the actions `button` and
|
|
5638
5695
|
// `destructive`. `Button` forwards a ref to its pressable.
|
|
5639
|
-
|
|
5696
|
+
// 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
|
|
5697
|
+
// seen online / today / in 7 or 30 days through the new
|
|
5698
|
+
// `ctx.directory.users.active`; `useUsers` takes `joined_since` and
|
|
5699
|
+
// `sort: "newest"` for an admin's "who joined" list.
|
|
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",
|
|
5640
5707
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5641
5708
|
hooks: HOOKS,
|
|
5642
5709
|
primitives: PRIMITIVES,
|