@colixsystems/widget-sdk 0.245.0 → 0.246.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 CHANGED
@@ -84,7 +84,42 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.245.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
+ `v0.246.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
88
+
89
+ ### What's new in 0.246.0 (contract 1.200.0)
90
+
91
+ **The standard components are the default, not a limit.** Every component description now says what it is for and when a request needs something it cannot draw — a colour-swatch picker, a tri-state tick box, a confirmation that asks you to type a name — in which case you build that one piece yourself from theme tokens, with the right accessibility role and state. Correctness rules are unchanged: screen-level surfaces still go through `<Overlay>`, irreversible actions are still confirmed, and formatted text is still markdown.
92
+
93
+ **Menus, popovers and tooltips anchored to their trigger.** `<Menu>` opens a list of actions from one control, `<Popover>` shows your own content beside it, and `<Tooltip>` names an icon-only button. They open beside the trigger on a wide screen, flip at the screen edge, become a bottom sheet on a phone, and close on the backdrop, Escape and Android back; no card or list clips them. A picked item's `onPress` runs once the menu has left the screen, so an item can open a `<ConfirmDialog>` that returns focus to the menu's button when it closes. On the web an anchored panel closes when the page scrolls beneath it. Menus are keyboard-navigable `menu`/`menuitem` lists; tooltips show on hover and focus on the web and on long press on touch. They follow Design > Components > Dropdown menus (`popover`), which gains `radius` and `shadow`; an unthemed `<Select>` is unchanged.
94
+
95
+ ```jsx
96
+ <Menu trigger={<IconButton icon="more-horizontal" accessibilityLabel="More actions" />}
97
+ items={[{ label: "Edit", icon: "pencil", onPress: edit },
98
+ canDelete && { label: "Delete", icon: "trash-2", danger: true, onPress: askDelete }]} />
99
+ ```
100
+
101
+ **Quiet buttons, icon buttons and busy states.** `<Button>` gains `variant="ghost"` and `variant="link"`, a `size` of `sm` (36px), `md` (44px, the default) or `lg` (52px), `busy` (a `<Spinner>` over the kept label; it cannot be pressed again) and `selected` for a toggle. `<IconButton icon accessibilityLabel>` is the same themed button as an icon-only square of at least 36px on both hosts. Both pass `onLongPress`, `aria-haspopup`, `aria-expanded` and their ref through, so they work as a `<Menu>`/`<Popover>` trigger and inside a `<Tooltip>`. Existing buttons render exactly as before.
102
+
103
+ ```jsx
104
+ <Button title="Save" onPress={save} busy={saving} />
105
+ <Button title="Load more" variant="ghost" onPress={next} />
106
+ <IconButton icon="trash-2" accessibilityLabel="Delete order" color={theme.colors.danger} onPress={remove} />
107
+ ```
108
+
109
+ **One way to show a stored picture: `<FileImage>` and `<Avatar fileId>`.** Give `<FileImage>` a FILE column's value and it resolves the file with `useFilestoreFile`, draws a `<Skeleton>` in the app's Loading look while it loads, and shows an image glyph (or a blank box) instead of a broken image. Signed URLs expire, so a failed load refetches the file once before giving up. Pass `uri` for a URL you already hold. `<Avatar fileId>` does the same for a person's photo and keeps the initials if it cannot load; `url` callers are unchanged.
110
+
111
+ ```jsx
112
+ <FileImage fileId={product.Photo} variant="card" aspectRatio={4 / 3} accessibilityLabel={product.Name} />
113
+ <Avatar name={member.Name} fileId={member.Photo} size="sm" />
114
+ ```
115
+
116
+ **`<RadioGroup>`: one choice from a short visible list.** It takes the form controls' `label`, `helper`, `error` and `disabled`, and options `{ value, label, description, icon, disabled }`. `variant="list"` (default) and `"card"` follow Design > Components > Inputs; `"segmented"` draws the Tabs scope's segmented strip. On the web the group is one tab stop, the arrows move the choice past disabled options and Space picks; on native a tap picks. A long list stays a `Select`.
117
+
118
+ ```jsx
119
+ <RadioGroup variant="card" label="Delivery" value={speed} onValueChange={setSpeed}
120
+ options={[{ value: "standard", label: "Standard", description: "3-5 working days" },
121
+ { value: "express", label: "Express", description: "Next working day" }]} />
122
+ ```
88
123
 
89
124
  ### What's new in 0.245.0 (contract 1.199.0)
90
125
 
@@ -3037,6 +3072,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
3037
3072
  - `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
3038
3073
  - `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
3039
3074
  - `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, picker and search box. Like `TextField` they follow the theme's `muted` scope, Design > Components > Inputs (and `popover`, Dropdown menus, for the Select's option sheet; `SearchField` also reads `search`, Search fields, for its glyph position, corner radius, background, border colour and height, each falling back to Inputs) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level; with `multiple` it takes `values` / `onValuesChange`, ticks options in the same sheet and commits them on Done. Never hand-roll one from `Pressable` and `View`.
3075
+ - `RadioGroup` — the themed pick-ONE of a short list that stays on screen. `<RadioGroup options value onValueChange variant>` with `variant` `"list"` (default) or `"card"`, both following `muted` (Design > Components > Inputs), or `"segmented"`, the `tabs` scope's segmented strip; options are `{ value, label, description, icon, disabled, testID }`, and it takes `label`, `helper`, `error` and `disabled`. Radio role and checked state on both hosts; on the web one tab stop, arrows move the choice. Use it by default for two to about six visible options; a long list is a `Select`, and a choice it cannot draw (a picture or a swatch per option) is yours to build, with the radio role.
3040
3076
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
3041
3077
  - `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.
3042
3078
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
@@ -0,0 +1,154 @@
1
+ // The variants, sizes and quiet looks behind `<Button>` and `<IconButton>`.
2
+ // Platform-free and React-free, so both hosts resolve one look and tests can
3
+ // pin it.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+
7
+ export const BUTTON_VARIANTS = Object.freeze([
8
+ "primary",
9
+ "secondary",
10
+ "outlined",
11
+ "danger",
12
+ "ghost",
13
+ "link",
14
+ ]);
15
+ export const BUTTON_SIZES = Object.freeze(["sm", "md", "lg"]);
16
+ export const DEFAULT_BUTTON_SIZE = "md";
17
+
18
+ // A quiet button has no chrome of its own, so the theme's fill, border,
19
+ // shadow and label colour (drawn for a filled button) never reach it.
20
+ const QUIET_VARIANTS = Object.freeze(["ghost", "link"]);
21
+ const THEME_PAINT_FIELDS = Object.freeze([
22
+ "background",
23
+ "gradient",
24
+ "textColor",
25
+ "borderColor",
26
+ "borderWidth",
27
+ "shadow",
28
+ "ringColor",
29
+ "ringWidth",
30
+ "dangerBackground",
31
+ "dangerBorderColor",
32
+ "dangerTextColor",
33
+ ]);
34
+
35
+ // The square an `<IconButton>` draws, and the glyph inside it.
36
+ export const ICON_BUTTON_SIZES = Object.freeze({
37
+ sm: Object.freeze({ box: 36, glyph: 16 }),
38
+ md: Object.freeze({ box: 44, glyph: 20 }),
39
+ lg: Object.freeze({ box: 52, glyph: 24 }),
40
+ });
41
+ // The touch target a native platform recommends; hitSlop reaches it on a
42
+ // device, while the web only ever has the box itself.
43
+ const NATIVE_TOUCH_TARGET = 44;
44
+
45
+ export function resolveButtonVariant(variant) {
46
+ return BUTTON_VARIANTS.includes(variant) ? variant : "primary";
47
+ }
48
+
49
+ export function resolveButtonSize(size) {
50
+ return BUTTON_SIZES.includes(size) ? size : DEFAULT_BUTTON_SIZE;
51
+ }
52
+
53
+ export function isQuietLook(variant, selected) {
54
+ return selected === true || QUIET_VARIANTS.includes(variant);
55
+ }
56
+
57
+ /**
58
+ * A variant's own paint before the section, the theme or the caller move it.
59
+ * `selected` is a toggle's on state: the accent's quiet tint, whatever the
60
+ * variant.
61
+ */
62
+ export function variantPaint(variant, colors, selected = false) {
63
+ if (selected) {
64
+ return {
65
+ container: { backgroundColor: colors.primarySoft || DEFAULT_THEME_TOKENS.colors.primarySoft },
66
+ ink: colors.onPrimarySoft || DEFAULT_THEME_TOKENS.colors.onPrimarySoft,
67
+ };
68
+ }
69
+ if (variant === "secondary") {
70
+ return {
71
+ container: { backgroundColor: colors.secondary, borderWidth: 1, borderColor: colors.border },
72
+ ink: colors.onSecondary,
73
+ };
74
+ }
75
+ if (variant === "danger") {
76
+ return {
77
+ container: { backgroundColor: colors.danger, borderWidth: 1, borderColor: colors.danger },
78
+ ink: colors.onPrimary,
79
+ };
80
+ }
81
+ if (variant === "outlined") {
82
+ return {
83
+ container: { backgroundColor: "transparent", borderWidth: 1, borderColor: colors.primary },
84
+ ink: colors.primary,
85
+ };
86
+ }
87
+ if (QUIET_VARIANTS.includes(variant)) {
88
+ return { container: { backgroundColor: "transparent" }, ink: colors.primary };
89
+ }
90
+ return { container: { backgroundColor: colors.primary }, ink: colors.onPrimary };
91
+ }
92
+
93
+ /**
94
+ * The theme-folded fields a quiet look keeps: its shape, weight, size and
95
+ * insets, but none of the paint the app's button look set for a filled
96
+ * button. A field the caller passed in `own` is always kept.
97
+ */
98
+ export function quietButtonFields(folded, own) {
99
+ const caller = own || {};
100
+ const kept = { ...folded };
101
+ for (const key of THEME_PAINT_FIELDS) {
102
+ if (caller[key] === undefined) delete kept[key];
103
+ }
104
+ return kept;
105
+ }
106
+
107
+ /** The insets, minimum size and label size of a button at `size`. */
108
+ export function buttonSizeFrame(size, variant, theme) {
109
+ const { spacing, typography } = theme;
110
+ const frames = {
111
+ sm: {
112
+ frame: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, minHeight: 36, minWidth: 48 },
113
+ fontSize: typography.sizes.sm,
114
+ },
115
+ md: {
116
+ frame: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, minHeight: 44, minWidth: 64 },
117
+ fontSize: typography.sizes.sm,
118
+ },
119
+ lg: {
120
+ frame: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 52, minWidth: 80 },
121
+ fontSize: typography.sizes.md,
122
+ },
123
+ };
124
+ const { frame, fontSize } = frames[resolveButtonSize(size)];
125
+ // A link reads as text, so it keeps its height but drops the side insets.
126
+ if (variant === "link") return { frame: { ...frame, paddingHorizontal: 0, minWidth: 0 }, fontSize };
127
+ return { frame, fontSize };
128
+ }
129
+
130
+ /** The square box and glyph size of an `<IconButton>`. */
131
+ export function iconButtonFrame(size) {
132
+ return ICON_BUTTON_SIZES[resolveButtonSize(size)];
133
+ }
134
+
135
+ /** What extends a box smaller than the native touch target up to it. */
136
+ export function iconButtonHitSlop(box) {
137
+ if (box >= NATIVE_TOUCH_TARGET) return undefined;
138
+ const reach = (NATIVE_TOUCH_TARGET - box) / 2;
139
+ return { top: reach, bottom: reach, left: reach, right: reach };
140
+ }
141
+
142
+ /**
143
+ * The colour of a busy button's `<Spinner>`: the label's ink on a fill the
144
+ * button paints, else unset so the spinner wears `theme.colors.loader`.
145
+ */
146
+ export function busySpinnerColor(fill, ink, gradient) {
147
+ const painted = Boolean(gradient) || (typeof fill === "string" && fill !== "transparent");
148
+ return painted ? ink : undefined;
149
+ }
150
+
151
+ /** The spinner diameter that sits inside a label of `fontSize`. */
152
+ export function busySpinnerSize(fontSize) {
153
+ return fontSize > 14 ? "md" : "sm";
154
+ }
@@ -1,11 +1,12 @@
1
- // The ONE button surface, behind the `<Button>` SDK primitive and the built-in
2
- // Button widget. The bindings (`button.js` / `button.native.js`) differ only in
3
- // their primitives and in which `<Gradient>` paints a gradient fill.
1
+ // The ONE button surface, behind the `<Button>` and `<IconButton>` SDK
2
+ // primitives and the built-in Button widget. The bindings (`button.js` /
3
+ // `button.native.js`) differ only in their primitives and in which
4
+ // `<Gradient>` and `<Spinner>` they pass.
4
5
 
5
6
  import React from "react";
6
7
  import { useHostTheme } from "./hooks.js";
7
8
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
- import { lucideGlyph } from "./icon.js";
9
+ import { Icon, lucideGlyph } from "./icon.js";
9
10
  import { applyThemeComponentStyle } from "./theme-components.js";
10
11
  import { cornerRadiusOverlay, shadowStyle, gradientProps } from "./card-style.js";
11
12
  import { flattenStyle } from "./flatten-style.js";
@@ -15,41 +16,41 @@ import {
15
16
  ownedButtonPaint,
16
17
  paintOnBackdrop,
17
18
  } from "./button-style.js";
19
+ import {
20
+ BUTTON_VARIANTS,
21
+ buttonSizeFrame,
22
+ busySpinnerColor,
23
+ busySpinnerSize,
24
+ iconButtonFrame,
25
+ iconButtonHitSlop,
26
+ isQuietLook,
27
+ quietButtonFields,
28
+ resolveButtonVariant,
29
+ variantPaint,
30
+ } from "./button-tokens.js";
18
31
 
19
- export const BUTTON_VARIANTS = Object.freeze(["primary", "secondary", "outlined", "danger"]);
32
+ export { BUTTON_VARIANTS };
20
33
 
21
34
  // The theme's button, destructive and ring scopes reach a custom widget's
22
35
  // button exactly as the host folds them onto the built-in one.
23
36
  const THEMED_AS = "appstudio.button";
24
-
25
- function variantPaint(variant, colors) {
26
- if (variant === "secondary") {
27
- return {
28
- container: { backgroundColor: colors.secondary, borderWidth: 1, borderColor: colors.border },
29
- ink: colors.onSecondary,
30
- };
31
- }
32
- if (variant === "danger") {
33
- return {
34
- container: { backgroundColor: colors.danger, borderWidth: 1, borderColor: colors.danger },
35
- ink: colors.onPrimary,
36
- };
37
- }
38
- if (variant === "outlined") {
39
- return {
40
- container: { backgroundColor: "transparent", borderWidth: 1, borderColor: colors.primary },
41
- ink: colors.primary,
42
- };
43
- }
44
- return { container: { backgroundColor: colors.primary }, ink: colors.onPrimary };
45
- }
37
+ const HIDDEN = { "aria-hidden": true, importantForAccessibility: "no-hide-descendants" };
38
+ const CENTRED_OVER = {
39
+ position: "absolute",
40
+ top: 0,
41
+ right: 0,
42
+ bottom: 0,
43
+ left: 0,
44
+ alignItems: "center",
45
+ justifyContent: "center",
46
+ };
46
47
 
47
48
  // Precedence, weakest first: variant, danger scope, ring, then the fields.
48
- function containerLayers(variant, f, colors, owned) {
49
- const paint = paintOnBackdrop(variant, variantPaint(variant, colors), colors, owned);
49
+ function containerLayers(variant, selected, f, colors, owned) {
50
+ const paint = paintOnBackdrop(variant, variantPaint(variant, colors, selected), colors, owned);
50
51
  let ink = paint.ink;
51
52
  const layers = [paint.container];
52
- if (variant === "danger") {
53
+ if (variant === "danger" && !selected) {
53
54
  if (f.dangerBackground) layers.push({ backgroundColor: f.dangerBackground });
54
55
  if (f.dangerBorderColor) layers.push({ borderWidth: 1, borderColor: f.dangerBorderColor });
55
56
  if (f.dangerTextColor) ink = f.dangerTextColor;
@@ -80,17 +81,105 @@ function wrapGlyph(View, glyph, key) {
80
81
  return React.createElement(View, { key }, glyph);
81
82
  }
82
83
 
84
+ /** The resolved fields, paint and gradient one button draws with. */
85
+ function useButtonLook({ variant, selected, fields, hostThemed, style, labelStyle }) {
86
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
87
+ const folded = hostThemed
88
+ ? fields || {}
89
+ : applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
90
+ const f = isQuietLook(variant, selected) ? quietButtonFields(folded, fields) : folded;
91
+ const owned = ownedButtonPaint(variant, f, flattenStyle(style), flattenStyle(labelStyle));
92
+ const { layers, ink } = containerLayers(variant, selected, f, theme.colors, owned);
93
+ return { theme, f, layers, ink, gradient: gradientProps(f.gradient) };
94
+ }
95
+
96
+ function fillOf(layers) {
97
+ return flattenStyle(layers).backgroundColor;
98
+ }
99
+
100
+ /**
101
+ * The state both hosts announce, as aria-* props (the web binding drops
102
+ * the older state object). React Native has no aria-pressed, so a toggle is
103
+ * aria-selected on a device.
104
+ */
105
+ function pressableState({ disabled, busy, selected, expanded }, web) {
106
+ const toggle = typeof selected === "boolean";
107
+ const opens = typeof expanded === "boolean";
108
+ return {
109
+ disabled: disabled || busy,
110
+ "aria-disabled": disabled || busy,
111
+ ...(busy ? { "aria-busy": true } : null),
112
+ ...(toggle ? { [web ? "aria-pressed" : "aria-selected"]: selected } : null),
113
+ ...(opens ? { "aria-expanded": expanded } : null),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * What `<Menu>`, `<Popover>` and `<Tooltip>` put on the control they wrap,
119
+ * so a button can open them and say whether it has.
120
+ */
121
+ function triggerProps(props) {
122
+ const expanded = props["aria-expanded"];
123
+ return {
124
+ expanded: typeof expanded === "boolean" ? expanded : undefined,
125
+ pass: {
126
+ ...(typeof props.onLongPress === "function" ? { onLongPress: props.onLongPress } : null),
127
+ ...(props["aria-haspopup"] ? { "aria-haspopup": props["aria-haspopup"] } : null),
128
+ ...(props["aria-describedby"] ? { "aria-describedby": props["aria-describedby"] } : null),
129
+ },
130
+ };
131
+ }
132
+
133
+ function pressable(props) {
134
+ return typeof props.onPress === "function" || typeof props.onLongPress === "function";
135
+ }
136
+
83
137
  /**
84
138
  * Bind the shared button to one platform's primitives.
85
139
  *
86
- * @param {{View: any, Text: any, Pressable: any, StyleSheet: any, Gradient: any}} rn
140
+ * @param {{View: any, Text: any, Pressable: any, StyleSheet: any, Gradient: any, Spinner: any, Platform: any}} rn
87
141
  */
88
142
  export function makeButton(rn) {
89
- const { View, Text, Pressable, StyleSheet, Gradient } = rn;
143
+ const { View, Text, Pressable, StyleSheet, Gradient, Spinner, Platform } = rn;
144
+ const web = Platform.OS === "web";
145
+
146
+ function gradientFill(gradient, radius, radii) {
147
+ return React.createElement(Gradient, {
148
+ key: "fill",
149
+ colors: gradient.colors,
150
+ angle: gradient.angle,
151
+ // Rounded rather than clipped: `overflow: hidden` drops an iOS shadow.
152
+ style: [
153
+ StyleSheet.absoluteFill,
154
+ { borderRadius: radii.sm, pointerEvents: "none" },
155
+ cornerRadiusOverlay(radius),
156
+ ],
157
+ });
158
+ }
159
+
160
+ // Over the hidden content, so a busy button keeps its size.
161
+ function busyMark(look, layers, fontSize) {
162
+ return React.createElement(
163
+ View,
164
+ { key: "busy", ...HIDDEN, style: CENTRED_OVER },
165
+ React.createElement(Spinner, {
166
+ size: busySpinnerSize(fontSize),
167
+ color: busySpinnerColor(fillOf(layers), look.ink, look.gradient),
168
+ }),
169
+ );
170
+ }
171
+
172
+ function pressedStyle(container, disabled) {
173
+ return ({ pressed }) => [...container, pressed && !disabled ? { opacity: 0.85 } : null];
174
+ }
90
175
 
91
176
  /**
92
177
  * @param {string} props.title — the label.
93
- * @param {string} [props.variant] — primary | secondary | outlined | danger.
178
+ * @param {string} [props.variant] — primary | secondary | outlined | danger | ghost | link.
179
+ * @param {string} [props.size] — sm | md (default) | lg.
180
+ * @param {boolean} [props.busy] — a pending action: a spinner over the
181
+ * kept label, and the button cannot be pressed.
182
+ * @param {boolean} [props.selected] — a toggle's state; omit for a plain button.
94
183
  * @param {object} [props.fields] — per-instance button-surface fields
95
184
  * (background, textColor, borderColor, borderWidth, paddingX, paddingY,
96
185
  * radius, fontWeight, fontSize, shadow, gradient, danger*, ring*).
@@ -100,50 +189,51 @@ export function makeButton(rn) {
100
189
  * @param {boolean} [props.hostThemed] — `fields` already carry the theme
101
190
  * (a built-in the host folded); skip the second fold, which would add the
102
191
  * Button widget's own scopes to a form submit or a Link action.
103
- * @param {object} ref — forwarded to the pressable, so a caller can focus it.
192
+ * @param {Function} [props.onLongPress] — a `<Tooltip>` shows on it.
193
+ * @param {string} [props["aria-haspopup"]] — set by a `<Menu>` or `<Popover>`.
194
+ * @param {boolean} [props["aria-expanded"]] — whether that surface is open.
195
+ * @param {object} ref — forwarded to the pressable, so a caller can focus
196
+ * it and a `<Menu>` or `<Tooltip>` can place itself beside it.
104
197
  */
105
- const Button = React.forwardRef(function Button({
106
- title,
107
- variant = "primary",
108
- icon,
109
- iconPosition = "leading",
110
- fields,
111
- onPress,
112
- disabled = false,
113
- style,
114
- labelStyle,
115
- hostThemed = false,
116
- accessibilityLabel,
117
- testID,
118
- }, ref) {
119
- const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
120
- const { colors, spacing, radii, typography } = theme;
121
- const f = hostThemed
122
- ? fields || {}
123
- : applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
124
- const resolvedVariant = BUTTON_VARIANTS.includes(variant) ? variant : "primary";
125
- const owned = ownedButtonPaint(
126
- resolvedVariant,
127
- f,
128
- flattenStyle(style),
129
- flattenStyle(labelStyle),
130
- );
131
- const { layers, ink } = containerLayers(resolvedVariant, f, colors, owned);
132
-
198
+ const Button = React.forwardRef(function Button(props, ref) {
199
+ const {
200
+ title,
201
+ variant = "primary",
202
+ size,
203
+ icon,
204
+ iconPosition = "leading",
205
+ fields,
206
+ onPress,
207
+ disabled = false,
208
+ busy = false,
209
+ selected,
210
+ style,
211
+ labelStyle,
212
+ hostThemed = false,
213
+ accessibilityLabel,
214
+ testID,
215
+ } = props;
216
+ const trigger = triggerProps(props);
217
+ const resolvedVariant = resolveButtonVariant(variant);
218
+ const look = useButtonLook({
219
+ variant: resolvedVariant,
220
+ selected: selected === true,
221
+ fields,
222
+ hostThemed,
223
+ style,
224
+ labelStyle,
225
+ });
226
+ const { theme, f, layers, ink, gradient } = look;
227
+ const { spacing, radii, typography } = theme;
228
+ const sized = buttonSizeFrame(size, resolvedVariant, theme);
133
229
  const fontSize =
134
- typeof f.fontSize === "number" && Number.isFinite(f.fontSize)
135
- ? f.fontSize
136
- : typography.sizes.sm;
230
+ typeof f.fontSize === "number" && Number.isFinite(f.fontSize) ? f.fontSize : sized.fontSize;
137
231
  const glyph = iconNode(icon, Math.round(fontSize), ink);
138
- const gradient = gradientProps(f.gradient);
139
232
 
140
233
  const container = [
141
234
  {
142
- paddingHorizontal: spacing.md,
143
- paddingVertical: spacing.sm,
235
+ ...sized.frame,
144
236
  borderRadius: radii.sm,
145
- minHeight: 44,
146
- minWidth: 64,
147
237
  alignItems: "center",
148
238
  justifyContent: "center",
149
239
  alignSelf: "flex-start",
@@ -158,9 +248,11 @@ export function makeButton(rn) {
158
248
  const label = React.createElement(
159
249
  Text,
160
250
  {
251
+ key: "label",
161
252
  style: [
162
- { fontSize: typography.sizes.sm, fontWeight: "600", fontFamily: typography.fontFamily },
253
+ { fontSize: sized.fontSize, fontWeight: "600", fontFamily: typography.fontFamily },
163
254
  { color: ink },
255
+ resolvedVariant === "link" ? { textDecorationLine: "underline" } : null,
164
256
  typeof f.fontSize === "number" ? { fontSize: f.fontSize } : null,
165
257
  labelStyle,
166
258
  buttonSurfaceTextOverlay(f),
@@ -168,44 +260,110 @@ export function makeButton(rn) {
168
260
  },
169
261
  title,
170
262
  );
171
- const children = [
172
- gradient
173
- ? React.createElement(Gradient, {
174
- key: "fill",
175
- colors: gradient.colors,
176
- angle: gradient.angle,
177
- // Rounded rather than clipped: `overflow: hidden` drops an iOS shadow.
178
- style: [
179
- StyleSheet.absoluteFill,
180
- { borderRadius: radii.sm, pointerEvents: "none" },
181
- cornerRadiusOverlay(f.radius),
182
- ],
183
- })
184
- : null,
263
+ const content = [
185
264
  glyph && iconPosition !== "trailing" ? wrapGlyph(View, glyph, "lead") : null,
186
- React.cloneElement(label, { key: "label" }),
265
+ label,
187
266
  glyph && iconPosition === "trailing" ? wrapGlyph(View, glyph, "trail") : null,
188
267
  ];
268
+ const children = [
269
+ gradient ? gradientFill(gradient, f.radius, radii) : null,
270
+ // Hidden, not removed: the button keeps its width while busy.
271
+ busy
272
+ ? React.createElement(View, { key: "content", style: { flexDirection: "row", gap: spacing.xs, opacity: 0 } }, content)
273
+ : content,
274
+ busy ? busyMark(look, layers, fontSize) : null,
275
+ ];
189
276
 
190
- if (typeof onPress !== "function") {
191
- return React.createElement(View, { style: container, testID, accessibilityLabel }, children);
277
+ if (!pressable(props)) {
278
+ return React.createElement(View, { ref, style: container, testID, accessibilityLabel }, children);
192
279
  }
193
280
  return React.createElement(
194
281
  Pressable,
195
282
  {
196
283
  ref,
197
284
  onPress,
198
- disabled,
285
+ ...trigger.pass,
286
+ ...pressableState({ disabled, busy, selected, expanded: trigger.expanded }, web),
199
287
  accessibilityRole: "button",
200
288
  accessibilityLabel: accessibilityLabel || (typeof title === "string" ? title : undefined),
201
- "aria-disabled": disabled,
202
289
  testID,
203
- style: ({ pressed }) => [...container, pressed && !disabled ? { opacity: 0.85 } : null],
290
+ style: pressedStyle(container, disabled || busy),
204
291
  },
205
292
  children,
206
293
  );
207
294
  });
208
-
209
295
  Button.displayName = "Button";
210
- return Button;
296
+
297
+ /**
298
+ * A square button that shows only an icon.
299
+ *
300
+ * @param {string} props.icon — a lucide name, drawn with the SDK `<Icon>`.
301
+ * @param {string} props.accessibilityLabel — required: the button's only name.
302
+ * @param {string} [props.variant] — ghost (default) | primary | secondary | outlined | danger.
303
+ * @param {string} [props.size] — sm (36) | md (44, default) | lg (52).
304
+ * @param {string} [props.color] — the glyph's colour, over the variant's.
305
+ * @param {Function} [props.onLongPress] — a `<Tooltip>` shows on it.
306
+ * @param {boolean} [props["aria-expanded"]] — set by a `<Menu>` or `<Popover>`.
307
+ */
308
+ const IconButton = React.forwardRef(function IconButton(props, ref) {
309
+ const {
310
+ icon,
311
+ accessibilityLabel,
312
+ variant = "ghost",
313
+ size,
314
+ color,
315
+ fields,
316
+ onPress,
317
+ disabled = false,
318
+ busy = false,
319
+ selected,
320
+ style,
321
+ testID,
322
+ } = props;
323
+ const trigger = triggerProps(props);
324
+ const resolvedVariant = resolveButtonVariant(variant === "link" ? "ghost" : variant);
325
+ const look = useButtonLook({
326
+ variant: resolvedVariant,
327
+ selected: selected === true,
328
+ fields,
329
+ hostThemed: false,
330
+ style,
331
+ });
332
+ const { theme, f, layers, ink, gradient } = look;
333
+ const { box, glyph } = iconButtonFrame(size);
334
+
335
+ const container = [
336
+ { borderRadius: theme.radii.sm, alignItems: "center", justifyContent: "center", alignSelf: "flex-start" },
337
+ ...layers,
338
+ // A real square: the web ignores hitSlop, so the box is the target.
339
+ { width: box, height: box, minWidth: box, minHeight: box, paddingHorizontal: 0, paddingVertical: 0 },
340
+ gradient ? { backgroundColor: "transparent" } : null,
341
+ disabled ? { opacity: 0.5 } : null,
342
+ style,
343
+ ];
344
+ const children = [
345
+ gradient ? gradientFill(gradient, f.radius, theme.radii) : null,
346
+ busy
347
+ ? busyMark(look, layers, glyph)
348
+ : React.createElement(View, { key: "glyph", ...HIDDEN }, React.createElement(Icon, { name: icon, size: glyph, color: color || ink })),
349
+ ];
350
+ return React.createElement(
351
+ Pressable,
352
+ {
353
+ ref,
354
+ onPress,
355
+ ...trigger.pass,
356
+ ...pressableState({ disabled, busy, selected, expanded: trigger.expanded }, web),
357
+ hitSlop: iconButtonHitSlop(box),
358
+ accessibilityRole: "button",
359
+ accessibilityLabel,
360
+ testID,
361
+ style: pressedStyle(container, disabled || busy),
362
+ },
363
+ children,
364
+ );
365
+ });
366
+ IconButton.displayName = "IconButton";
367
+
368
+ return { Button, IconButton };
211
369
  }
package/dist/button.js CHANGED
@@ -1,10 +1,12 @@
1
- // `<Button>` (web). Binds ./button-view.js to react-native-web and the CSS
2
- // gradient; see ./primitives.js for why not the bare specifier.
1
+ // `<Button>` and `<IconButton>` (web). Binds ./button-view.js to
2
+ // react-native-web, the CSS gradient and the web `<Spinner>`; see
3
+ // ./primitives.js for why not the bare specifier.
3
4
 
4
5
  import * as ReactNative from "react-native-web";
5
6
  import { Gradient } from "./gradient.js";
7
+ import { Spinner } from "./loading.js";
6
8
  import { makeButton, BUTTON_VARIANTS } from "./button-view.js";
7
9
 
8
10
  export { BUTTON_VARIANTS };
9
11
 
10
- export const Button = makeButton({ ...ReactNative, Gradient });
12
+ export const { Button, IconButton } = makeButton({ ...ReactNative, Gradient, Spinner });
@@ -1,10 +1,12 @@
1
- // `<Button>` (native). Binds ./button-view.js to React Native and the
2
- // expo-linear-gradient `<Gradient>`; only the imports differ from ./button.js.
1
+ // `<Button>` and `<IconButton>` (native). Binds ./button-view.js to React
2
+ // Native, the expo-linear-gradient `<Gradient>` and the native `<Spinner>`;
3
+ // only the imports differ from ./button.js.
3
4
 
4
- import { View, Text, Pressable, StyleSheet } from "react-native";
5
+ import { View, Text, Pressable, StyleSheet, Platform } from "react-native";
5
6
  import { Gradient } from "./gradient.native.js";
7
+ import { Spinner } from "./loading.native.js";
6
8
  import { makeButton, BUTTON_VARIANTS } from "./button-view.js";
7
9
 
8
10
  export { BUTTON_VARIANTS };
9
11
 
10
- export const Button = makeButton({ View, Text, Pressable, StyleSheet, Gradient });
12
+ export const { Button, IconButton } = makeButton({ View, Text, Pressable, StyleSheet, Gradient, Spinner, Platform });