@colixsystems/widget-sdk 0.245.0 → 0.247.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.
Files changed (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -6,6 +6,8 @@
6
6
  // `<Overlay>`, at screen level, so no card or scroll container the widget
7
7
  // sits in can clip them; a long list scrolls inside the sheet. With
8
8
  // `multiple` the same sheet ticks options into a draft that Done commits.
9
+ // Searchable, the sheet leads with a `<SearchField>`: the list filters where
10
+ // it is held, or each pause in typing asks `onSearch` / async `options`.
9
11
 
10
12
  import React from "react";
11
13
  import { useHostTheme } from "./hooks.js";
@@ -13,10 +15,9 @@ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
15
  import { Icon } from "./icon.js";
14
16
  import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
15
17
  import {
16
- CONTROL_MIN_TARGET,
17
18
  FIELD_OUTLINE_OFF,
18
19
  fieldStateStyle,
19
- nextOptionIndex,
20
+ optionRowStyle,
20
21
  resolveFormControlTokens,
21
22
  selectedOption,
22
23
  } from "./form-control-tokens.js";
@@ -25,10 +26,11 @@ import {
25
26
  hasNote,
26
27
  isShown,
27
28
  isText,
29
+ optionKeyDown,
28
30
  shareRef,
29
- spaceActivates,
30
31
  spokenLabel,
31
32
  } from "./form-control-logic.js";
33
+ import { popoverPanelStyle } from "./popover-tokens.js";
32
34
  import { makeFieldFrame } from "./form-control-view.js";
33
35
  import { checkboxBox } from "./checkbox-view.js";
34
36
  import {
@@ -41,9 +43,20 @@ import {
41
43
  triggerSummary,
42
44
  valueList,
43
45
  } from "./select-logic.js";
46
+ import {
47
+ DEFAULT_SEARCH_DEBOUNCE_MS,
48
+ createDebouncer,
49
+ createRequestGate,
50
+ filterOptions,
51
+ knownOption,
52
+ knownOptions,
53
+ normaliseQuery,
54
+ rememberOptions,
55
+ searchRowOverrides,
56
+ sheetBody,
57
+ } from "./select-search-logic.js";
44
58
 
45
59
  const h = React.createElement;
46
- const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
47
60
  // Wide enough that no picked chip shrinks while it is measured.
48
61
  const MEASURE_SPAN = 10000;
49
62
  // A compact chip that fits the field's own line, so the trigger keeps its height.
@@ -54,10 +67,12 @@ const UNMEASURED = Object.freeze({ available: null, needed: null });
54
67
  * Bind the shared select to one platform's primitives.
55
68
  *
56
69
  * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any,
57
- * Overlay: any, Button: any, Chip: any}} rn
70
+ * Overlay: any, Button: any, Chip: any, SearchField?: any, Spinner?: any}} rn
71
+ * — `SearchField` and `Spinner` are needed only by a searchable select.
58
72
  */
59
73
  export function makeSelect(rn) {
60
- const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip } = rn;
74
+ const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner } =
75
+ rn;
61
76
  const FieldFrame = makeFieldFrame(rn);
62
77
 
63
78
  // Keyboard focus is a web concern: a device moves its reader's focus into
@@ -67,14 +82,31 @@ export function makeSelect(rn) {
67
82
  }
68
83
 
69
84
  function OptionSheet(props) {
70
- const { t, options, isPicked, multiple, title, emptyText, listId, onPick, testID, footer } =
71
- props;
85
+ const {
86
+ t,
87
+ options,
88
+ isPicked,
89
+ multiple,
90
+ title,
91
+ emptyText,
92
+ listId,
93
+ onPick,
94
+ testID,
95
+ footer,
96
+ search,
97
+ body = options.length === 0 ? "empty" : "list",
98
+ bodyText,
99
+ rowStyle,
100
+ } = props;
72
101
  const nodes = React.useRef([]);
73
102
  const initial = initialFocusIndex(options, isPicked);
74
103
  // Focus lands on the current choice once, when the sheet mounts it; the
75
- // browser scrolls a focused option into view in a long list.
76
- const pendingFocus = React.useRef(true);
104
+ // browser scrolls a focused option into view in a long list. A search
105
+ // field takes that first focus instead.
106
+ const pendingFocus = React.useRef(!search);
77
107
  const text = { fontFamily: t.fontFamily, fontSize: t.fontSize, color: t.popover.textColor };
108
+ const note = { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal };
109
+ const focusFirst = () => focusNode(nodes.current[initialFocusIndex(options, () => false)]);
78
110
 
79
111
  return h(
80
112
  View,
@@ -86,11 +118,23 @@ export function makeSelect(rn) {
86
118
  title,
87
119
  )
88
120
  : null,
89
- options.length === 0
121
+ search ? search(focusFirst) : null,
122
+ body === "loading"
123
+ ? h(
124
+ View,
125
+ { style: { paddingVertical: t.rowGap }, testID: testID ? `${testID}-loading` : undefined },
126
+ h(Spinner, { size: "sm", label: bodyText }),
127
+ )
128
+ : body !== "list"
90
129
  ? h(
91
130
  Text,
92
- { style: { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal } },
93
- emptyText,
131
+ {
132
+ style: body === "error" ? { ...note, color: t.errorColor } : note,
133
+ // Results arrive after typing; a status line says what came back.
134
+ ...(search ? { role: "status", accessibilityLiveRegion: "polite" } : null),
135
+ testID: search && testID ? `${testID}-${body}` : undefined,
136
+ },
137
+ body === "empty" ? emptyText : bodyText,
94
138
  )
95
139
  : h(
96
140
  ScrollView,
@@ -123,38 +167,39 @@ export function makeSelect(rn) {
123
167
  }
124
168
  },
125
169
  onPress: pick,
126
- onKeyDown: (event) => {
127
- if (NAVIGATION_KEYS.includes(event.key)) {
128
- event.preventDefault();
129
- focusNode(nodes.current[nextOptionIndex(options, index, event.key)]);
130
- return;
131
- }
132
- spaceActivates(pick)(event);
133
- },
170
+ onKeyDown: optionKeyDown({
171
+ options,
172
+ index,
173
+ focusAt: (next) => focusNode(nodes.current[next]),
174
+ activate: pick,
175
+ }),
134
176
  disabled: option.disabled,
135
177
  role: "option",
136
178
  "aria-selected": selected,
137
179
  "aria-disabled": !!option.disabled,
138
180
  testID: option.testID,
139
181
  style: ({ pressed }) => [
140
- {
141
- flexDirection: "row",
142
- alignItems: "center",
182
+ ...optionRowStyle(t, {
183
+ pressed,
184
+ disabled: option.disabled,
143
185
  justifyContent: multiple ? "flex-start" : "space-between",
144
- gap: t.rowGap,
145
- minHeight: CONTROL_MIN_TARGET,
146
- paddingHorizontal: t.paddingHorizontal,
147
- paddingVertical: t.paddingVertical,
148
- borderRadius: t.radius,
149
- },
150
- pressed ? { opacity: 0.7 } : null,
151
- option.disabled ? { opacity: t.disabledOpacity } : null,
186
+ }),
187
+ rowStyle,
152
188
  ],
153
189
  },
154
190
  multiple ? checkboxBox(View, t, { checked: selected }) : null,
191
+ option.leading ? h(View, { style: { flexShrink: 0 } }, option.leading) : null,
155
192
  h(
156
193
  Text,
157
- { style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
194
+ {
195
+ style: [
196
+ text,
197
+ { flexShrink: 1 },
198
+ // Keeps the check mark at the end once a leading node is drawn.
199
+ option.leading ? { flexGrow: 1 } : null,
200
+ selected ? { fontWeight: "600" } : null,
201
+ ],
202
+ },
158
203
  option.label,
159
204
  ),
160
205
  selected && !multiple ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
@@ -241,6 +286,50 @@ export function makeSelect(rn) {
241
286
  );
242
287
  }
243
288
 
289
+ // The sheet's search field and what it shows instead of the list.
290
+ function searchSheetProps({ theme, search, count, loading, failed, texts, testID }) {
291
+ const body = sheetBody({ loading, error: failed, query: search.text, count });
292
+ return {
293
+ body,
294
+ bodyText:
295
+ body === "loading"
296
+ ? texts.loadingText
297
+ : body === "error"
298
+ ? texts.loadErrorText
299
+ : texts.noResultsText,
300
+ rowStyle: searchRowOverrides(theme),
301
+ search: (focusFirst) =>
302
+ h(SheetSearchField, {
303
+ value: search.text,
304
+ onChangeText: search.type,
305
+ placeholder: texts.searchPlaceholder,
306
+ onArrowDown: focusFirst,
307
+ testID: testID ? `${testID}-search` : undefined,
308
+ }),
309
+ };
310
+ }
311
+
312
+ function SheetSearchField({ value, onChangeText, placeholder, onArrowDown, testID }) {
313
+ const node = React.useRef(null);
314
+ React.useEffect(() => {
315
+ focusNode(node.current);
316
+ }, []);
317
+ return h(SearchField, {
318
+ ref: node,
319
+ value,
320
+ onChangeText,
321
+ placeholder,
322
+ // Down from the field reaches the first option, as in a combobox.
323
+ onKeyPress: (event) => {
324
+ const key = event && event.nativeEvent ? event.nativeEvent.key : undefined;
325
+ if (key !== "ArrowDown") return;
326
+ if (typeof event.preventDefault === "function") event.preventDefault();
327
+ onArrowDown();
328
+ },
329
+ testID,
330
+ });
331
+ }
332
+
244
333
  const Select = React.forwardRef(function Select(props, ref) {
245
334
  const {
246
335
  value,
@@ -265,6 +354,16 @@ export function makeSelect(rn) {
265
354
  testID,
266
355
  onFocus,
267
356
  onBlur,
357
+ searchable: searchableProp = false,
358
+ onSearch,
359
+ searchDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
360
+ loading: loadingProp = false,
361
+ optionsError,
362
+ selectedOptions,
363
+ searchPlaceholder = "Search",
364
+ noResultsText = "No results",
365
+ loadingText = "Loading…",
366
+ loadErrorText = "Could not load the options",
268
367
  ...others
269
368
  } = props;
270
369
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
@@ -283,9 +382,27 @@ export function makeSelect(rn) {
283
382
  const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
284
383
  const listId = React.useId();
285
384
  const noteId = React.useId();
286
- const list = Array.isArray(options) ? options.filter(Boolean) : [];
287
- const current = selectedOption(list, value);
288
- const picked = multiple ? pickedOptions(list, values) : [];
385
+ const search = useSearchState({
386
+ open,
387
+ options,
388
+ searchable: searchableProp,
389
+ onSearch,
390
+ debounceMs: searchDebounceMs,
391
+ });
392
+ const { searchable } = search;
393
+ const held = search.fetched
394
+ ? search.fetched.options
395
+ : Array.isArray(options)
396
+ ? options.filter(Boolean)
397
+ : [];
398
+ const list = search.filtersHeld ? filterOptions(held, search.text) : held;
399
+ // Searchable, a picked option keeps its label after a search drops it.
400
+ const known = React.useRef(new Map());
401
+ if (searchable) known.current = rememberOptions(known.current, selectedOptions, held);
402
+ const optionsFor = (own) =>
403
+ searchable ? knownOptions(known.current, own) : pickedOptions(list, own);
404
+ const current = searchable ? knownOption(known.current, value) : selectedOption(list, value);
405
+ const picked = multiple ? optionsFor(values) : [];
289
406
  const draft = ownDraft === null ? valueList(values) : ownDraft;
290
407
  const invalid = isShown(error);
291
408
  const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
@@ -307,7 +424,7 @@ export function makeSelect(rn) {
307
424
  const next = committedValues(list, draft);
308
425
  close();
309
426
  if (!sameValues(next, valueList(values)) && typeof onValuesChange === "function") {
310
- onValuesChange(next, pickedOptions(list, next));
427
+ onValuesChange(next, optionsFor(next));
311
428
  }
312
429
  };
313
430
  const pick = (option) => {
@@ -417,12 +534,7 @@ export function makeSelect(rn) {
417
534
  size: "sm",
418
535
  accessibilityLabel: isText(sheetTitle) ? String(sheetTitle) : name,
419
536
  ...(closeAccessibilityLabel ? { closeAccessibilityLabel } : null),
420
- style: {
421
- backgroundColor: t.popover.background,
422
- borderWidth: 1,
423
- borderColor: t.popover.borderColor,
424
- padding: t.rowGap,
425
- },
537
+ style: { ...popoverPanelStyle(t.popover, "dialog"), padding: t.rowGap },
426
538
  },
427
539
  h(OptionSheet, {
428
540
  t,
@@ -437,6 +549,23 @@ export function makeSelect(rn) {
437
549
  testID,
438
550
  onPick: pick,
439
551
  footer: multiple ? h(DoneFooter, { doneText, onDone: done, testID }) : null,
552
+ ...(searchable
553
+ ? searchSheetProps({
554
+ theme,
555
+ search,
556
+ count: list.length,
557
+ loading:
558
+ search.pending || (search.fetched ? search.fetched.loading : !!loadingProp),
559
+ failed: search.fetched ? search.fetched.error : isShown(optionsError),
560
+ texts: {
561
+ searchPlaceholder,
562
+ noResultsText,
563
+ loadingText,
564
+ loadErrorText: typeof optionsError === "string" ? optionsError : loadErrorText,
565
+ },
566
+ testID,
567
+ })
568
+ : null),
440
569
  }),
441
570
  ),
442
571
  );
@@ -445,3 +574,86 @@ export function makeSelect(rn) {
445
574
  Select.displayName = "Select";
446
575
  return Select;
447
576
  }
577
+
578
+ /**
579
+ * A searchable select's query: the text typed into the sheet, sent after a
580
+ * pause to `onSearch` or to async `options`, whose latest answer it keeps.
581
+ * Opening the sheet starts from an empty query; a plain select is inert here.
582
+ */
583
+ function useSearchState({ open, options, searchable, onSearch, debounceMs }) {
584
+ const asyncOptions = typeof options === "function";
585
+ const remote = typeof onSearch === "function" || asyncOptions;
586
+ const enabled = !!searchable || remote;
587
+ const [text, setText] = React.useState("");
588
+ // The query last handed on; typed text that differs is still pending.
589
+ const [sentQuery, setSentQuery] = React.useState("");
590
+ const [fetched, setFetched] = React.useState({ options: [], loading: false, error: false });
591
+ const latest = React.useRef({ onSearch, options });
592
+ latest.current = { onSearch, options };
593
+ const gate = React.useRef(null);
594
+ if (gate.current === null) gate.current = createRequestGate();
595
+ const mounted = React.useRef(false);
596
+ // Set in the body too: StrictMode runs the cleanup, then the effect again.
597
+ React.useEffect(() => {
598
+ mounted.current = true;
599
+ return () => {
600
+ mounted.current = false;
601
+ };
602
+ }, []);
603
+
604
+ const send = React.useCallback((query) => {
605
+ setSentQuery(query);
606
+ const own = latest.current;
607
+ if (typeof own.onSearch === "function") own.onSearch(query);
608
+ if (typeof own.options !== "function") return;
609
+ const ticket = gate.current.begin();
610
+ setFetched((state) => ({ ...state, loading: true, error: false }));
611
+ Promise.resolve()
612
+ .then(() => own.options(query))
613
+ .then(
614
+ (rows) => {
615
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
616
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
617
+ setFetched({ options: list, loading: false, error: false });
618
+ },
619
+ () => {
620
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
621
+ setFetched({ options: [], loading: false, error: true });
622
+ },
623
+ );
624
+ }, []);
625
+
626
+ const delay = React.useRef(debounceMs);
627
+ delay.current = debounceMs;
628
+ const debouncer = React.useRef(null);
629
+ if (debouncer.current === null) debouncer.current = createDebouncer(() => delay.current, send);
630
+ React.useEffect(() => () => debouncer.current.cancel(), []);
631
+
632
+ // Each opening starts from an empty query, asked for at once; it is
633
+ // cleared on closing so the next opening never draws the old filter.
634
+ React.useEffect(() => {
635
+ if (!enabled) return;
636
+ if (!open) {
637
+ debouncer.current.cancel();
638
+ setText("");
639
+ return;
640
+ }
641
+ if (remote) send("");
642
+ // eslint-disable-next-line react-hooks/exhaustive-deps
643
+ }, [open, enabled, remote]);
644
+
645
+ const type = (next) => {
646
+ const own = typeof next === "string" ? next : "";
647
+ setText(own);
648
+ if (remote) debouncer.current.push(own);
649
+ };
650
+
651
+ return {
652
+ searchable: enabled,
653
+ filtersHeld: enabled && !remote,
654
+ text: enabled ? text : "",
655
+ pending: remote && normaliseQuery(text) !== sentQuery,
656
+ type,
657
+ fetched: asyncOptions ? fetched : null,
658
+ };
659
+ }
package/dist/select.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // `<Select>` (web). Binds ./select-view.js to react-native-web and the web
2
- // `<Overlay>`, `<Button>` and `<Chip>`; see ./primitives.js for why not the
3
- // bare specifier.
2
+ // `<Overlay>`, `<Button>`, `<Chip>`, `<SearchField>` and `<Spinner>`; see
3
+ // ./primitives.js for why not the bare specifier.
4
4
 
5
5
  import * as ReactNative from "react-native-web";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.js";
8
8
  import { Button } from "./button.js";
9
9
  import { Chip } from "./chip.js";
10
+ import { SearchField } from "./search-field.js";
11
+ import { Spinner } from "./loading.js";
10
12
 
11
13
  export const Select = makeSelect({
12
14
  View: ReactNative.View,
@@ -17,4 +19,6 @@ export const Select = makeSelect({
17
19
  Overlay,
18
20
  Button,
19
21
  Chip,
22
+ SearchField,
23
+ Spinner,
20
24
  });
@@ -1,11 +1,13 @@
1
1
  // `<Select>` (native). Binds ./select-view.js to React Native's own
2
- // primitives and the native `<Overlay>`, `<Button>` and `<Chip>`; only the
3
- // imports differ from ./select.js.
2
+ // primitives and the native `<Overlay>`, `<Button>`, `<Chip>`,
3
+ // `<SearchField>` and `<Spinner>`; only the imports differ from ./select.js.
4
4
 
5
5
  import { View, Text, Pressable, ScrollView, Platform } from "react-native";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.native.js";
8
8
  import { Button } from "./button.native.js";
9
9
  import { Chip } from "./chip.native.js";
10
+ import { SearchField } from "./search-field.native.js";
11
+ import { Spinner } from "./loading.native.js";
10
12
 
11
- export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip });
13
+ export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner });
@@ -67,3 +67,62 @@ export function resolveTabsTokens(theme, overrides) {
67
67
  rule: colors.border,
68
68
  };
69
69
  }
70
+
71
+ const SEGMENT_INSET = 4;
72
+
73
+ /**
74
+ * The segmented strip's surfaces: the muted bar, its inset and each segment.
75
+ * `<Tabs>` and a segmented `<RadioGroup>` both draw from it, so they match.
76
+ * A concentric outer radius keeps the inset chip's corners parallel, and the
77
+ * active segment is outlined as well as lifted: a shadow alone disappears on
78
+ * a dark theme.
79
+ *
80
+ * @param {object} t — resolved tab tokens.
81
+ * @param {object} theme — the resolved workspace theme.
82
+ */
83
+ export function segmentedStripStyles(t, theme) {
84
+ const elevation = (theme && theme.elevation) || DEFAULT_THEME_TOKENS.elevation;
85
+ return {
86
+ bar: {
87
+ backgroundColor: t.barBackground,
88
+ borderRadius: t.radius + 2,
89
+ alignSelf: "flex-start",
90
+ maxWidth: "100%",
91
+ },
92
+ content: { padding: SEGMENT_INSET, gap: SEGMENT_INSET },
93
+ item: (active) => ({
94
+ borderRadius: t.radius,
95
+ borderWidth: 1,
96
+ borderColor: active ? t.rule : "transparent",
97
+ ...(active ? { backgroundColor: t.activeSurface, ...(elevation.sm || {}) } : null),
98
+ }),
99
+ };
100
+ }
101
+
102
+ /** One tab or segment's box, before its style's own surface. */
103
+ export const TAB_ITEM_LAYOUT = Object.freeze({
104
+ flexDirection: "row",
105
+ alignItems: "center",
106
+ justifyContent: "center",
107
+ gap: 6,
108
+ minHeight: 44,
109
+ paddingHorizontal: 12,
110
+ paddingVertical: 8,
111
+ });
112
+
113
+ /**
114
+ * A tab or segment's label.
115
+ *
116
+ * @param {object} t — resolved tab tokens.
117
+ * @param {object} theme — the resolved workspace theme.
118
+ * @param {boolean} active
119
+ */
120
+ export function tabLabelStyle(t, theme, active) {
121
+ const typography = (theme && theme.typography) || DEFAULT_THEME_TOKENS.typography;
122
+ return {
123
+ fontFamily: typography.fontFamily,
124
+ fontSize: typography.sizes.sm,
125
+ fontWeight: active ? "600" : "500",
126
+ color: active ? t.activeTextColor : t.textColor,
127
+ };
128
+ }
package/dist/tabs-view.js CHANGED
@@ -10,13 +10,15 @@ import React from "react";
10
10
  import { useHostTheme } from "./hooks.js";
11
11
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
12
12
  import { Icon } from "./icon.js";
13
- import { resolveTabsTokens } from "./tabs-tokens.js";
14
-
15
- const SEGMENT_INSET = 4;
13
+ import {
14
+ TAB_ITEM_LAYOUT,
15
+ resolveTabsTokens,
16
+ segmentedStripStyles,
17
+ tabLabelStyle,
18
+ } from "./tabs-tokens.js";
16
19
 
17
20
  function stripStyles(t, theme) {
18
21
  const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
19
- const elevation = theme.elevation || DEFAULT_THEME_TOKENS.elevation;
20
22
  if (t.style === "pill") {
21
23
  // An idle chip is outlined: a muted fill vanishes on a white card.
22
24
  return {
@@ -42,26 +44,7 @@ function stripStyles(t, theme) {
42
44
  }),
43
45
  };
44
46
  }
45
- // A concentric outer radius keeps the inset chip's corners parallel. The
46
- // bar hugs its segments, and the active one is outlined as well as lifted
47
- // because a shadow alone disappears on a dark theme.
48
- return {
49
- bar: {
50
- backgroundColor: t.barBackground,
51
- borderRadius: t.radius + 2,
52
- alignSelf: "flex-start",
53
- maxWidth: "100%",
54
- },
55
- content: { padding: SEGMENT_INSET, gap: SEGMENT_INSET },
56
- item: (active) => ({
57
- borderRadius: t.radius,
58
- borderWidth: 1,
59
- borderColor: active ? t.rule : "transparent",
60
- ...(active
61
- ? { backgroundColor: t.activeSurface, ...(elevation.sm || {}) }
62
- : null),
63
- }),
64
- };
47
+ return segmentedStripStyles(t, theme);
65
48
  }
66
49
 
67
50
  /**
@@ -98,7 +81,6 @@ export function makeTabs(rn) {
98
81
  textColor,
99
82
  radius,
100
83
  });
101
- const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
102
84
  const strip = stripStyles(t, theme);
103
85
 
104
86
  return React.createElement(
@@ -130,15 +112,7 @@ export function makeTabs(rn) {
130
112
  "aria-disabled": !!item.disabled,
131
113
  testID: item.testID,
132
114
  style: [
133
- {
134
- flexDirection: "row",
135
- alignItems: "center",
136
- justifyContent: "center",
137
- gap: 6,
138
- minHeight: 44,
139
- paddingHorizontal: 12,
140
- paddingVertical: 8,
141
- },
115
+ TAB_ITEM_LAYOUT,
142
116
  strip.item(active),
143
117
  item.disabled ? { opacity: 0.5 } : null,
144
118
  ],
@@ -148,15 +122,7 @@ export function makeTabs(rn) {
148
122
  : null,
149
123
  React.createElement(
150
124
  Text,
151
- {
152
- numberOfLines: 1,
153
- style: {
154
- fontFamily: typography.fontFamily,
155
- fontSize: typography.sizes.sm,
156
- fontWeight: active ? "600" : "500",
157
- color: ink,
158
- },
159
- },
125
+ { numberOfLines: 1, style: tabLabelStyle(t, theme, active) },
160
126
  item.label,
161
127
  ),
162
128
  );