@gnome-ui/react-native 1.5.0 → 1.7.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 (43) hide show
  1. package/README.md +647 -3
  2. package/dist/components/AvatarGroup/AvatarGroup.d.ts +47 -0
  3. package/dist/components/AvatarGroup/index.d.ts +2 -0
  4. package/dist/components/AvatarRotator/AvatarRotator.d.ts +65 -0
  5. package/dist/components/AvatarRotator/index.d.ts +2 -0
  6. package/dist/components/Bin/Bin.d.ts +18 -0
  7. package/dist/components/Bin/index.d.ts +2 -0
  8. package/dist/components/Box/Box.d.ts +87 -0
  9. package/dist/components/Box/index.d.ts +2 -0
  10. package/dist/components/Clamp/Clamp.d.ts +61 -0
  11. package/dist/components/Clamp/index.d.ts +2 -0
  12. package/dist/components/CoachMark/CoachMark.d.ts +92 -0
  13. package/dist/components/CoachMark/CoachMarkTour.d.ts +54 -0
  14. package/dist/components/CoachMark/coachMarkUtils.d.ts +42 -0
  15. package/dist/components/CoachMark/index.d.ts +5 -0
  16. package/dist/components/ColorPicker/ColorPicker.d.ts +81 -0
  17. package/dist/components/ColorPicker/ColorSwatch.d.ts +50 -0
  18. package/dist/components/ColorPicker/index.d.ts +4 -0
  19. package/dist/components/ComboRow/ComboRow.d.ts +77 -0
  20. package/dist/components/ComboRow/index.d.ts +2 -0
  21. package/dist/components/EntryRow/EntryRow.d.ts +77 -0
  22. package/dist/components/EntryRow/index.d.ts +2 -0
  23. package/dist/components/InlineViewSwitcher/InlineViewSwitcher.d.ts +93 -0
  24. package/dist/components/InlineViewSwitcher/InlineViewSwitcherItem.d.ts +25 -0
  25. package/dist/components/InlineViewSwitcher/index.d.ts +4 -0
  26. package/dist/components/InlineViewSwitcher/variants.d.ts +30 -0
  27. package/dist/components/PasswordEntryRow/PasswordEntryRow.d.ts +45 -0
  28. package/dist/components/PasswordEntryRow/index.d.ts +2 -0
  29. package/dist/components/PreferencesGroup/PreferencesGroup.d.ts +52 -0
  30. package/dist/components/PreferencesGroup/index.d.ts +2 -0
  31. package/dist/components/StatusPage/StatusPage.d.ts +79 -0
  32. package/dist/components/StatusPage/index.d.ts +2 -0
  33. package/dist/components/ToggleGroup/ToggleGroup.d.ts +70 -0
  34. package/dist/components/ToggleGroup/ToggleGroupItem.d.ts +49 -0
  35. package/dist/components/ToggleGroup/index.d.ts +4 -0
  36. package/dist/components/WrapBox/WrapBox.d.ts +67 -0
  37. package/dist/components/WrapBox/index.d.ts +2 -0
  38. package/dist/index.cjs +1 -1
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.ts +15 -0
  41. package/dist/index.js +2057 -645
  42. package/dist/index.js.map +1 -1
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -17,12 +17,16 @@ React Native component library following the [GNOME Human Interface Guidelines](
17
17
  > `PathBar`) fully ported. Tier 4 Feedback: `Spinner`, `ProgressBar`,
18
18
  > `Skeleton`, `Toast`/`Toaster`, `Banner`, `Dialog`, `Tooltip`, and
19
19
  > `AnimatedIcon` (which brought a new `Icon` component along with it, as its
20
- > own public component) shipped — `Status Page` skipped for now. Tier 5
20
+ > own public component) and `StatusPage` shipped — Tier 4 complete. Tier 5
21
21
  > Advanced Controls fully ported: `Dropdown`, `Slider`, `SpinButton`,
22
22
  > `Avatar`, `Badge`, and `Popover`. Beyond Tier 5, `BottomSheet` (Tier 14)
23
23
  > and `Overlay`/`LevelBar`/`Expander`/`Divider`/`Highlight`/`FileTypeIcon`/
24
- > `SegmentedBar` (Tier 20), `Chip` (Tier 7), and `IconButton`/`Drawer`
25
- > (Tier 8/Tier 20) also shipped. Component ports from
24
+ > `SegmentedBar`/`AvatarGroup`/`AvatarRotator`/`CoachMark`/`CoachMarkTour`
25
+ > (Tier 20), `Chip` (Tier 7), `IconButton`/`Drawer` (Tier 8/Tier 20), and
26
+ > `Clamp` (Tier 6), `Box` (Tier 20), `WrapBox`/`ToggleGroup` (Tier 7), and
27
+ > `InlineViewSwitcher` (Tier 8), `PreferencesGroup` (Tier 13), and
28
+ > `EntryRow`/`PasswordEntryRow`/`ComboRow` (Tier 12), `ColorPicker`
29
+ > (Tier 20), and `Bin` (Tier 15) also shipped. Component ports from
26
30
  > `@gnome-ui/react` continue tier by tier — see this package's own
27
31
  > [ROADMAP.md](./ROADMAP.md) for full
28
32
  > per-tier status against all 130 `@gnome-ui/react` components, and the
@@ -1430,6 +1434,646 @@ no exit keyframes at all, so this follows `Dialog`'s simpler animation
1430
1434
  shape instead. `backdrop-filter: blur(4px)` has no port (no native blur
1431
1435
  dependency in this package).
1432
1436
 
1437
+ ### AvatarGroup
1438
+
1439
+ ```tsx
1440
+ import { AvatarGroup } from '@gnome-ui/react-native';
1441
+
1442
+ <AvatarGroup
1443
+ avatars={[{ name: 'Alice Martin' }, { name: 'Bob Smith' }, { name: 'Carol White' }]}
1444
+ max={5}
1445
+ />
1446
+ ```
1447
+
1448
+ Overlapping stack of `Avatar`s with a "+N" overflow indicator. Mirrors
1449
+ `@gnome-ui/react`'s `AvatarGroup`. The web version's separating ring
1450
+ around each overlapping avatar is two layered `box-shadow`s (an inset 1px
1451
+ dark/light border plus an outset 2px window-colored ring) — RN can only
1452
+ give a `View` one border, so this keeps just the outer window-colored
1453
+ ring (overriding `Avatar`'s own subtle 1px ring via its `style` prop),
1454
+ since that's the ring doing the actual "stay visually distinct from the
1455
+ avatar behind you" work. The overflow chip reuses `Avatar`'s own per-size
1456
+ box dimensions so it lines up exactly with the avatars beside it.
1457
+ `role="group"` + an auto-generated `accessibilityLabel` (joined names,
1458
+ plus "and N more" when overflowing) port 1:1 from RN's newer web-aligned
1459
+ `Role` union.
1460
+
1461
+ ### AvatarRotator
1462
+
1463
+ ```tsx
1464
+ import { AvatarRotator } from '@gnome-ui/react-native';
1465
+
1466
+ <AvatarRotator name="Alice Martin" avatars={[url1, url2, url3]} />
1467
+ ```
1468
+
1469
+ Single avatar surface that crossfades through multiple image sources.
1470
+ Mirrors `@gnome-ui/react`'s `AvatarRotator`. Keeps `Avatar` focused on
1471
+ rendering one identity, while this component owns timing, crossfade
1472
+ animation, and pause behavior.
1473
+
1474
+ Each source renders as its own absolutely-positioned `Avatar`, crossfaded
1475
+ with `Animated.timing` (`useNativeDriver: true`) — a `RotatorLayer`
1476
+ sub-component owns each layer's own `Animated.Value` rather than the
1477
+ parent tracking an array of them, the same "each item animates itself"
1478
+ shape `Toast`/`Toaster` already established for independently
1479
+ transitioning list items. `prefers-reduced-motion` stops the rotation
1480
+ outright, not just the fade — ported exactly from the web version's own
1481
+ auto-advance effect, which bails out early on both `isPaused` and reduced
1482
+ motion alike. `pauseOnHover` becomes `pauseOnPress`
1483
+ (`onPressIn`/`onPressOut`) — the same touch substitution `Toast`'s own
1484
+ press-and-hold pause already established, kept as a real toggleable prop
1485
+ here (defaults `true`).
1486
+
1487
+ ### CoachMark / CoachMarkTour
1488
+
1489
+ ```tsx
1490
+ import { CoachMark, CoachMarkTour } from '@gnome-ui/react-native';
1491
+
1492
+ <CoachMark
1493
+ open={open}
1494
+ targetRef={target}
1495
+ title="Sync your files"
1496
+ description="Press this to keep every device up to date."
1497
+ primaryAction={{ label: 'Got it', onPress: () => setOpen(false) }}
1498
+ onDismiss={() => setOpen(false)}
1499
+ />
1500
+
1501
+ <CoachMarkTour
1502
+ open={running}
1503
+ steps={[
1504
+ { targetRef: searchRef, title: 'Search', description: 'Find anything fast.' },
1505
+ { targetRef: addRef, title: 'Add', description: 'Create a new item here.', placement: 'left' },
1506
+ ]}
1507
+ onFinish={() => setRunning(false)}
1508
+ onSkip={() => setRunning(false)}
1509
+ />
1510
+ ```
1511
+
1512
+ Spotlights a target element and anchors a callout bubble (title,
1513
+ description, step counter, actions) beside it, guiding a user to one
1514
+ feature. Compose several with `CoachMarkTour`, or drive one directly with
1515
+ `open`. Mirrors `@gnome-ui/react`'s `CoachMark`/`CoachMarkTour` — not a
1516
+ GNOME HIG widget, a pragmatic feature-discovery pattern.
1517
+
1518
+ Positions with the same two-pass viewport-aware flip as the web version
1519
+ (`coachMarkUtils.ts`, duplicated verbatim — pure math, no DOM), resolved
1520
+ from `targetRef.current?.measureInWindow(...)` and the bubble's own
1521
+ `onLayout` size. **The measurement is deliberately delayed (a real
1522
+ `setTimeout`, not just one `requestAnimationFrame`)** — confirmed
1523
+ on-device that measuring too early catches a stale rect when the target
1524
+ sits below sibling content whose own size isn't final on the first commit
1525
+ (e.g. a multi-line description `Text` above it); a single rAF still
1526
+ landed before the follow-up layout pass accounted for it.
1527
+
1528
+ The spotlight cutout has no CSS `box-shadow: 0 0 0 100vmax` port — that
1529
+ trick paints an opaque scrim everywhere except inside a rounded rect via a
1530
+ huge spread shadow, which RN's real OS shadows can't reproduce. Rebuilt as
1531
+ four plain `View` bands around the padded target rect, plus a separate
1532
+ rounded `accentColor`-bordered ring on top — the whole overlay sits inside
1533
+ one full-screen `Pressable`, so a tap anywhere within it (including
1534
+ visually "in the hole") triggers `dismissOnBackdrop`, matching the web
1535
+ version exactly. `dismissOnBackdrop` has no effect when `spotlight` is
1536
+ `false` — ported faithfully, not fixed: the web source only renders a
1537
+ backdrop element at all when `spotlight` is true. The arrow reuses
1538
+ `Popover`/`Tooltip`'s transparent-border-triangle trick rather than the
1539
+ web CSS's rotated-45°-square, offset along the bubble's edge by
1540
+ `arrowOffset` from the position math (unlike `Tooltip`/`Popover`'s simpler
1541
+ always-centered arrow). No focus trap and no scroll/resize
1542
+ re-positioning, the same established gaps for a transient RN floating
1543
+ element. `CoachMarkTour` is pure state orchestration on top of
1544
+ `CoachMark`, ported verbatim.
1545
+
1546
+ ### Clamp
1547
+
1548
+ ```tsx
1549
+ import { Clamp } from '@gnome-ui/react-native';
1550
+
1551
+ <Clamp>
1552
+ <BoxedList>{/* … */}</BoxedList>
1553
+ </Clamp>
1554
+
1555
+ <Clamp maximumSize={480} tighteningThreshold={0.9}>
1556
+ <Text>Never wider than 480 dp, and never edge-to-edge below it</Text>
1557
+ </Clamp>
1558
+ ```
1559
+
1560
+ Constrains its children to a maximum width while letting them shrink freely
1561
+ — mirrors `@gnome-ui/react`'s `Clamp` and the Adwaita `AdwClamp` widget. Use
1562
+ it on settings pages and forms so content never becomes too wide to read
1563
+ comfortably on a tablet or a landscape phone, while still filling the width
1564
+ on a narrow one. `maximumSize` defaults to **600** (the Adwaita recommended
1565
+ narrow-content width) and is in density-independent pixels, not CSS px.
1566
+ Adds no padding of its own.
1567
+
1568
+ The web version's `margin-inline: auto` centering becomes
1569
+ `alignSelf: 'center'` rather than `marginHorizontal: 'auto'` — RN
1570
+ auto-margin support was left unverified for this Yoga version back when
1571
+ `Drawer` needed the same trick, so this follows `Drawer`'s resolution of
1572
+ using flex alignment instead. The one consequence is that `Clamp` expects a
1573
+ column-direction parent (RN's default): `alignSelf` acts on the cross axis,
1574
+ so inside a `flexDirection: 'row'` parent it would centre vertically. Wrap
1575
+ it in a plain `View` there.
1576
+
1577
+ `tighteningThreshold` is a real percentage width here, unlike in
1578
+ `@gnome-ui/react`, where the prop is declared and documented but never
1579
+ reaches the DOM — implementing it exactly as that package documents it (a
1580
+ fraction of the available width, still capped by `maximumSize`) costs
1581
+ nothing on RN and avoids shipping a dead prop.
1582
+
1583
+ ### Box
1584
+
1585
+ ```tsx
1586
+ import { Box } from '@gnome-ui/react-native';
1587
+
1588
+ // Vertical section (heading + content)
1589
+ <Box spacing={12}>
1590
+ <Text variant="caption-heading" color="dim">Devices</Text>
1591
+ <BoxedList>{/* … */}</BoxedList>
1592
+ </Box>
1593
+
1594
+ // Horizontal icon + label
1595
+ <Box orientation="horizontal" spacing={6} align="center">
1596
+ <Icon icon={Folder} size="sm" />
1597
+ <Text>Documents</Text>
1598
+ </Box>
1599
+ ```
1600
+
1601
+ Fundamental flex layout primitive — the RN equivalent of `GtkBox`, and a 1:1
1602
+ mirror of `@gnome-ui/react`'s own `Box`. Arranges children in a row or
1603
+ column with consistent spacing from the GNOME HIG scale: **3** (tight) ·
1604
+ **6** (standard, the default) · **12** (medium) · **18** (large) · **24**
1605
+ (section) · **32** (loose) · **48** (jumbo), all in dp. `align` defaults to
1606
+ `"stretch"` when vertical and `"center"` when horizontal, `justify` to
1607
+ `"start"`.
1608
+
1609
+ `BoxSpacing` keeps the web package's exact seven values rather than being
1610
+ remapped onto this package's own `theme.space1`–`space6` scale — the two
1611
+ overlap at 6/12/18/24/48 but not at 3 or 32/36, and `BoxSpacing` is a
1612
+ published type consumers may already be importing, so it ports verbatim.
1613
+
1614
+ Two things the web version accepts don't survive the platform. `spacing`
1615
+ and `padding` are numbers only — RN's `gap`/`padding` take dp, not CSS
1616
+ strings like `"1rem"`. And `align`/`justify`, which the web hands straight
1617
+ to CSS, are mapped internally from their bare `start`/`end` keywords onto
1618
+ Yoga's `flex-start`/`flex-end`; the prop values stay the web ones, so the
1619
+ API reads identically across both packages. `display: 'flex'` needs no port
1620
+ at all — every RN `View` is already a flex container.
1621
+
1622
+ ### WrapBox
1623
+
1624
+ ```tsx
1625
+ import { WrapBox } from '@gnome-ui/react-native';
1626
+
1627
+ // Tag list
1628
+ <WrapBox>
1629
+ {tags.map((tag) => <Chip key={tag} label={tag} />)}
1630
+ </WrapBox>
1631
+
1632
+ // Tight between items, loose between lines
1633
+ <WrapBox childSpacing={6} lineSpacing={18} justify="center">
1634
+ {filters.map((filter) => <Chip key={filter} label={filter} />)}
1635
+ </WrapBox>
1636
+ ```
1637
+
1638
+ Flexible wrapping layout container — children flow horizontally and wrap to
1639
+ new lines when they don't fit, like words in a paragraph, without locking
1640
+ them into a grid. Mirrors `AdwWrapBox` (libadwaita 1.7 / GNOME 48) and
1641
+ `@gnome-ui/react`'s own `WrapBox`. Pair with `Chip` for tag lists and filter
1642
+ rows.
1643
+
1644
+ `childSpacing` (default **6**) is the gap between items on a line;
1645
+ `lineSpacing` is the gap between lines and falls back to `childSpacing` when
1646
+ omitted — passing `0` really means zero, not "fall back". `justify` defaults
1647
+ to `"start"` and `align` to `"center"`; `wrapReverse` stacks lines bottom to
1648
+ top.
1649
+
1650
+ The web version ships its values as CSS custom properties consumed by a
1651
+ stylesheet (`--wrapbox-gap`, `--wrapbox-justify`, …) because a CSS module
1652
+ can't take runtime values any other way — RN has no such indirection, so
1653
+ they're written straight onto the style object. `flex-flow: row wrap`
1654
+ becomes `flexDirection: 'row'` + `flexWrap`, and the CSS shorthand
1655
+ `gap: <row> <column>` splits into RN's separate `rowGap`/`columnGap`; the
1656
+ single `gap` property would set both, which is precisely what this component
1657
+ has to be able to avoid. As in `Box`, the spacing props are numbers only
1658
+ (dp, not CSS strings) and `align`/`justify` keep the web's bare `start`/`end`
1659
+ keywords while mapping internally onto Yoga's `flex-start`/`flex-end`.
1660
+
1661
+ `alignContent: 'stretch'` is set explicitly even though neither package
1662
+ exposes an `alignContent` prop: **CSS defaults it to `stretch`, Yoga defaults
1663
+ it to `flex-start`**, so without it `align="stretch"` silently does nothing
1664
+ whenever the children have no cross-size of their own — the line collapses to
1665
+ zero height before `alignItems` gets to stretch anything into it. Caught
1666
+ on-device; it's a no-op in the ordinary case where the container hugs its
1667
+ content rather than having a fixed height.
1668
+
1669
+ ### StatusPage
1670
+
1671
+ ```tsx
1672
+ import { StatusPage } from '@gnome-ui/react-native';
1673
+
1674
+ <StatusPage
1675
+ icon={StarOutline}
1676
+ title="No favorites yet"
1677
+ description="Packages you star will show up here."
1678
+ >
1679
+ <Button variant="suggested" onPress={onAdd}>Add a package</Button>
1680
+ </StatusPage>
1681
+
1682
+ // For sidebars, popovers, and small panels
1683
+ <StatusPage compact icon={Search} title="No results" />
1684
+ ```
1685
+
1686
+ Empty-state / status page following the Adwaita `AdwStatusPage` pattern —
1687
+ mirrors `@gnome-ui/react`'s `StatusPage`. Use it to fill a view with no
1688
+ content yet, an error state, or a completion confirmation. Always explain
1689
+ *why* the view is empty and *what the user can do* about it; don't use it
1690
+ for loading states, where `Spinner` or `ProgressBar` belong instead.
1691
+ `compact` scales padding, icon size, title variant, description
1692
+ variant/measure and both action-area gaps down together.
1693
+
1694
+ It centres its content on both axes, but — exactly as in the web version —
1695
+ the vertical centring only does anything once a parent gives it height: put
1696
+ it in a `flex: 1` container to fill the view.
1697
+
1698
+ The title renders through this package's `Text` at `variant="title-1"`
1699
+ (`"title-4"` when `compact`), so it also picks up `Text`'s automatic
1700
+ `header` accessibility role — a deliberate divergence from the web
1701
+ version's `<p class="title">`. That `<p>` exists because HTML forces you to
1702
+ pick a concrete `h1`–`h6` level for a component that can't know where it
1703
+ sits in the document outline; RN's `header` role carries no level, so the
1704
+ dilemma disappears. On a touch device the rotor is the only structural
1705
+ navigation a screen reader user has, which makes the role worth having.
1706
+
1707
+ `max-width: 36ch` on the description has no RN unit to port to. `ch` is the
1708
+ advance width of "0", ≈ 0.5em in the sans faces Adwaita uses, so the cap is
1709
+ resolved against the description's own font size — 288 dp at body size, 216
1710
+ dp at caption size — keeping the measure font-relative the way the CSS is,
1711
+ rather than freezing one pixel value that `compact` would get wrong. The
1712
+ icon is dimmed by its wrapper's opacity (0.55 light / 0.45 dark, the two
1713
+ values the web's own `prefers-color-scheme` block hardcodes) and hidden
1714
+ from assistive tech with the `accessibilityElementsHidden` +
1715
+ `importantForAccessibility="no"` pair used in place of `aria-hidden`. The
1716
+ action area is a `WrapBox` rather than a hand-rolled row — `.actions` is a
1717
+ centred wrapping flex row with a gap and nothing else.
1718
+
1719
+ ### ToggleGroup / ToggleGroupItem
1720
+
1721
+ ```tsx
1722
+ import { ToggleGroup, ToggleGroupItem } from '@gnome-ui/react-native';
1723
+
1724
+ const [align, setAlign] = useState('left');
1725
+
1726
+ <ToggleGroup value={align} onValueChange={setAlign} accessibilityLabel="Alignment">
1727
+ <ToggleGroupItem name="left" icon={FormatJustifyLeft} accessibilityLabel="Left" />
1728
+ <ToggleGroupItem name="center" icon={FormatJustifyCenter} accessibilityLabel="Center" />
1729
+ <ToggleGroupItem name="right" icon={FormatJustifyRight} accessibilityLabel="Right" />
1730
+ </ToggleGroup>
1731
+
1732
+ // Items can be icon-only, label-only, or icon + label
1733
+ <ToggleGroupItem name="grid" icon={Applications} label="Grid" />
1734
+ ```
1735
+
1736
+ Mutually-exclusive group of toggle buttons for in-place option selection —
1737
+ mirrors `AdwToggleGroup` (libadwaita 1.7 / GNOME 48) and
1738
+ `@gnome-ui/react`'s own `ToggleGroup`. Use it for formatting controls,
1739
+ view-mode selectors and toolbar options, wherever a `ViewSwitcher` would be
1740
+ too heavy or doesn't belong in a `HeaderBar`. For icon-only items always
1741
+ pass an `accessibilityLabel`.
1742
+
1743
+ The context and its `value`/`onValueChange` shape port 1:1 — pure React. The
1744
+ keyboard layer doesn't: the web version owns an `onKeyDown` implementing
1745
+ ← / → cycling and Home / End jumps over a roving `tabIndex`, none of which
1746
+ has a touch counterpart, so it drops per this package's standing convention
1747
+ (set by `ViewSwitcher` and `TabBar`). The `radiogroup`/`radio` + `checked`
1748
+ pairing that VoiceOver and TalkBack actually announce carries the semantics
1749
+ instead.
1750
+
1751
+ The group sets `accessibilityRole="radiogroup"` but deliberately **not**
1752
+ `accessible` — on iOS, `accessible` on a container collapses the whole
1753
+ subtree into a single accessibility element, which would make the individual
1754
+ toggles unreachable for VoiceOver. Without it the role still groups on
1755
+ Android while every item stays focusable on its own.
1756
+
1757
+ Three `color-mix(in srgb, accent N%, transparent)` values resolve to 8-digit
1758
+ `#RRGGBBAA` hexes off `theme.accentBgColor` (the `Chip` precedent for the
1759
+ same selected-tint problem), so the tint follows the app's configurable
1760
+ accent color. The CSS paints its active ring as an `inset` box-shadow, which
1761
+ RN has no equivalent for — it becomes a real `borderWidth: 1` that every
1762
+ item carries at all times (transparent when inactive) so selecting one never
1763
+ shifts the row's layout, the substitution `AvatarGroup` already made for its
1764
+ own ring. `box-shadow: var(--gnome-shadow-sm)` on the group is dropped
1765
+ rather than approximated: the theme generator keeps shadow tokens in `raw`
1766
+ only, and `Card` already established that a border carries the same
1767
+ separation here. `:hover` collapses away and `:active` maps to `Pressable`'s
1768
+ `pressed` using `theme.activeOverlay`, whose light/dark values match the
1769
+ CSS's own `:active` colors exactly.
1770
+
1771
+ The icon keeps the default foreground color instead of tracking the active
1772
+ accent text — `Icon` has no `currentColor` equivalent and its `color` prop
1773
+ is a fixed GNOME palette with no `accent` member, which couldn't follow a
1774
+ configurable accent anyway. Same call, same reason, as `Chip`.
1775
+
1776
+ ### InlineViewSwitcher / InlineViewSwitcherItem
1777
+
1778
+ ```tsx
1779
+ import { InlineViewSwitcher, InlineViewSwitcherItem } from '@gnome-ui/react-native';
1780
+
1781
+ const [view, setView] = useState('grid');
1782
+
1783
+ <InlineViewSwitcher value={view} onValueChange={setView} variant="pill">
1784
+ <InlineViewSwitcherItem name="grid" label="Grid" icon={Applications} />
1785
+ <InlineViewSwitcherItem name="list" label="List" icon={ViewSidebar} />
1786
+ </InlineViewSwitcher>
1787
+
1788
+ // Collapse to a BottomSheet picker when the items stop fitting
1789
+ <InlineViewSwitcher value={view} onValueChange={setView} overflow="menu">
1790
+ {/* … */}
1791
+ </InlineViewSwitcher>
1792
+ ```
1793
+
1794
+ Compact inline view switcher for content areas, cards and toolbars —
1795
+ wherever `ViewSwitcher` (header-bar sized) would be too heavy. Mirrors
1796
+ `AdwInlineViewSwitcher` (libadwaita 1.7 / GNOME 48) and `@gnome-ui/react`'s
1797
+ own `InlineViewSwitcher`. Four variants — `default` (card surface + border),
1798
+ `flat` (indicator only), `round` (pill container, solid accent indicator),
1799
+ `pill` (segmented-control look, no accent) — and four overflow strategies:
1800
+ `wrap`, `scroll`, `compact`, `menu`.
1801
+
1802
+ Almost none of the *mechanism* ports, so this is a rebuild rather than a
1803
+ transliteration:
1804
+
1805
+ - **The sliding indicator** is measured, not laid out. The web reads the
1806
+ active button's `offsetLeft`/`offsetWidth`; here each item reports its own
1807
+ `onLayout` up through the context and the indicator animates `translateX` +
1808
+ `width` on **one JS-driven animation** (`useNativeDriver: false`). `width`
1809
+ can't be native-driven and mixing a native with a JS value on one component
1810
+ throws — the trade-off `Expander` already accepted for its animated height.
1811
+ `scaleX` would have been native-driveable but distorts the corner radii the
1812
+ variants are defined by. `useReducedMotion()` snaps it into place instead.
1813
+ - **Overflow detection** replaces `ResizeObserver` + `scrollWidth` vs
1814
+ `clientWidth` with the item measurements already being collected: their
1815
+ summed natural widths (RN leaves `flexShrink` at 0, so an overflowing row
1816
+ still reports each item at full width) against the row's own `onLayout`.
1817
+ The web's `naturalWidthRef` capture and 30 px hysteresis port verbatim —
1818
+ without them, collapsing the labels shrinks the content and immediately
1819
+ re-expands it.
1820
+ - **`overflow="scroll"`** becomes a horizontal `ScrollView` with the
1821
+ scrollbar hidden; `scroll-snap-align: start` has no RN style, but the
1822
+ measured item offsets feed `snapToOffsets`, which reproduces it exactly.
1823
+ - **`overflow="menu"`** reuses the already-shipped `BottomSheet`.
1824
+
1825
+ The ←/→/Home/End keyboard layer drops as everywhere else here, and — as in
1826
+ `ToggleGroup` — the group takes `accessibilityRole="radiogroup"` but
1827
+ deliberately not `accessible`, which on iOS would collapse the items into one
1828
+ unreachable element.
1829
+
1830
+ One divergence is a fix, not a port: the web applies its `.active` class to
1831
+ the menu trigger even though menu mode hides the indicator, which paints
1832
+ `round`'s trigger label in `accent-fg` (#fff) on a plain card — white on
1833
+ white. The RN trigger uses the idle color.
1834
+
1835
+ ### PreferencesGroup
1836
+
1837
+ ```tsx
1838
+ import { PreferencesGroup } from '@gnome-ui/react-native';
1839
+
1840
+ <PreferencesGroup
1841
+ title="Appearance"
1842
+ description="How the app looks on this device."
1843
+ headerSuffix={<Button variant="flat" onPress={reset}>Reset</Button>}
1844
+ >
1845
+ <BoxedList>{rows}</BoxedList>
1846
+ </PreferencesGroup>
1847
+ ```
1848
+
1849
+ Titled section that wraps a `BoxedList` with an optional description —
1850
+ mirrors `AdwPreferencesGroup` and `@gnome-ui/react`'s own
1851
+ `PreferencesGroup`. Use it to group related settings under a named heading.
1852
+ It's purely a layout and labelling wrapper: it doesn't render the
1853
+ `BoxedList` itself, you pass one as `children`. All three header parts are
1854
+ optional; with none of them the header row is omitted entirely.
1855
+
1856
+ The web's empty `.content` wrapper looks like dead markup but is
1857
+ load-bearing, so it's kept. The group is a 12 dp-gap flex column — without
1858
+ that wrapper every child would become a flex item of the group and pick up a
1859
+ 12 dp gap between the rows themselves, instead of one gap between the header
1860
+ and the content as a whole.
1861
+
1862
+ The title renders as `Text variant="body"` with an explicit semibold weight
1863
+ rather than `variant="heading"`, which is body-sized but **bold** and on the
1864
+ tighter heading line-height; the CSS `.title` is specifically semibold at the
1865
+ body line-height. It keeps the `header` accessibility role anyway (passed
1866
+ explicitly), since a settings-group heading is exactly the kind of landmark a
1867
+ screen reader rotor should list — the same call `StatusPage` makes for its
1868
+ own title. `min-width: 0` on the header text has no port and needs none: it's
1869
+ the classic CSS flexbox override for a min-content floor Yoga doesn't apply
1870
+ in the first place.
1871
+
1872
+ ### EntryRow
1873
+
1874
+ ```tsx
1875
+ import { EntryRow } from '@gnome-ui/react-native';
1876
+
1877
+ const [name, setName] = useState('');
1878
+
1879
+ <BoxedList>
1880
+ <EntryRow title="Display name" value={name} onValueChange={setName} />
1881
+ <EntryRow
1882
+ title="Email"
1883
+ value={email}
1884
+ onValueChange={setEmail}
1885
+ keyboardType="email-address"
1886
+ leading={<Icon icon={MailRead} />}
1887
+ trailing={<IconButton icon={Delete} label="Clear" onPress={() => setEmail('')} />}
1888
+ />
1889
+ </BoxedList>
1890
+ ```
1891
+
1892
+ Row with an inline text entry field — mirrors `AdwEntryRow` and
1893
+ `@gnome-ui/react`'s own `EntryRow`. The `title` rises above the input as a
1894
+ small label once the field is focused or has content, and stands in for the
1895
+ placeholder until then. Use it inside a `BoxedList` for settings that take
1896
+ free-form text. Controlled (`value`) and uncontrolled (`defaultValue`) modes
1897
+ both work, and every remaining `TextInput` prop passes through.
1898
+
1899
+ The float is one JS-driven `Animated.Value` (`useNativeDriver: false`):
1900
+ `fontSize` is part of the transition and can't be native-driven, and mixing a
1901
+ native with a JS value on one component throws — the same trade-off
1902
+ `Expander` and `InlineViewSwitcher` already accepted. `useReducedMotion()`
1903
+ snaps between the two states instead.
1904
+
1905
+ **The label's travel is measured, not hardcoded.** The web expresses the
1906
+ resting position as `top: 50%; transform: translateY(-50%)` and the floated
1907
+ one as `top: 6px`, but RN can't interpolate between a percentage and a fixed
1908
+ offset — so the field reports its own height through `onLayout` and the
1909
+ distance is derived from it, which also keeps the label centred if you make
1910
+ the row taller than the 56 dp minimum.
1911
+
1912
+ The `:focus` inset ring is dropped rather than approximated. `TextField`'s
1913
+ own precedent — recolor the border on focus — doesn't transfer, because an
1914
+ `EntryRow` has no border of its own: it's a row inside a `BoxedList`, and
1915
+ adding one would shift the list's geometry. On a touch device the state is
1916
+ already unmistakable: the label floats up, the text fades in, and the
1917
+ keyboard opens.
1918
+
1919
+ Two deliberate divergences from the web version. The visible label is hidden
1920
+ from assistive tech and the `title` becomes the input's `accessibilityLabel`
1921
+ — RN has no `<label htmlFor>`, so otherwise the label would be announced as
1922
+ loose text next to an unnamed field (pass `accessibilityLabel` to override).
1923
+ And `testID` lands on the row rather than the input, matching every other
1924
+ component in this package; reach the field itself by its accessible name.
1925
+
1926
+ ### PasswordEntryRow
1927
+
1928
+ ```tsx
1929
+ import { PasswordEntryRow } from '@gnome-ui/react-native';
1930
+
1931
+ <BoxedList>
1932
+ <PasswordEntryRow title="Password" value={password} onValueChange={setPassword} />
1933
+
1934
+ {/* Registration and change-password forms */}
1935
+ <PasswordEntryRow
1936
+ title="New password"
1937
+ value={next}
1938
+ onValueChange={setNext}
1939
+ autoComplete="new-password"
1940
+ />
1941
+ </BoxedList>
1942
+ ```
1943
+
1944
+ Password entry row with a built-in reveal/conceal toggle — mirrors
1945
+ `AdwPasswordEntryRow` and `@gnome-ui/react`'s own `PasswordEntryRow`. It's an
1946
+ `EntryRow` that masks its input and always carries a trailing button to show
1947
+ or hide what's been typed, so don't add your own through `trailing` — that
1948
+ slot is for anything that should sit *before* the reveal button.
1949
+
1950
+ `type={revealed ? 'text' : 'password'}` becomes RN's `secureTextEntry`, and
1951
+ `autoComplete` defaults to `"current-password"`, which is what lets password
1952
+ managers and the platform keyboard offer a saved credential.
1953
+
1954
+ The reveal control is the already-shipped `IconButton` rather than a
1955
+ hand-rolled pressable, which costs one visual detail: `IconButton` is
1956
+ circular (it's `Button` at `shape="circular"`) where the web's
1957
+ `.revealButton` is a 32 dp square with a 6 dp radius. A circular flat icon
1958
+ button is the idiomatic touch control and keeps the row consistent with every
1959
+ other icon action here. The CSS's resting `opacity: 0.55` is dropped too — it
1960
+ exists so the button can brighten on hover, and with no hover on a touch
1961
+ device a permanently dimmed control is just harder to see.
1962
+
1963
+ The web needs `e.stopPropagation()` so pressing the button doesn't also
1964
+ trigger the row's focus-the-input click. RN's responder system routes a touch
1965
+ to the innermost pressable, so there's nothing to stop.
1966
+
1967
+ ### ComboRow
1968
+
1969
+ ```tsx
1970
+ import { ComboRow } from '@gnome-ui/react-native';
1971
+
1972
+ <BoxedList>
1973
+ <ComboRow
1974
+ title="Language"
1975
+ subtitle="Used across the whole app"
1976
+ value={language}
1977
+ onValueChange={setLanguage}
1978
+ options={[
1979
+ { value: 'en', label: 'English' },
1980
+ { value: 'es', label: 'Español' },
1981
+ ]}
1982
+ />
1983
+ </BoxedList>
1984
+ ```
1985
+
1986
+ Settings row with an inline combo selector at the trailing edge — mirrors
1987
+ `AdwComboRow` and `@gnome-ui/react`'s own `ComboRow`. Use it inside a
1988
+ `BoxedList` for a setting that picks one of a set of options. Controlled
1989
+ (`value`) and uncontrolled (`defaultValue`) modes both work; the trigger
1990
+ falls back to `"—"` when nothing is selected.
1991
+
1992
+ **This is a composition of `ActionRow` + `Dropdown`, where the web version
1993
+ hand-rolls its own listbox inline** — around 200 lines re-implementing the
1994
+ trigger, the flip-up placement, outside-click dismissal, roving
1995
+ `aria-activedescendant` and the whole keyboard layer, none of which is
1996
+ meaningfully different from that package's own `Dropdown`. Nothing forced the
1997
+ duplication visually either: `.row` is `ActionRow`'s exact metrics and
1998
+ `.trigger` is `Dropdown`'s exact trigger. Composing the two already-shipped
1999
+ components means the flip-to-fit placement, the tap-outside dismissal and the
2000
+ `Modal`-based list all come along for free rather than being rebuilt.
2001
+
2002
+ `Dropdown` is controlled-only, so the uncontrolled state lives in `ComboRow`
2003
+ — same behaviour as the web version, one level up. The keyboard layer drops
2004
+ as it does everywhere else here.
2005
+
2006
+ ### ColorPicker / ColorSwatch
2007
+
2008
+ ```tsx
2009
+ import { ColorPicker, ColorSwatch, GNOME_PALETTE } from '@gnome-ui/react-native';
2010
+
2011
+ const [color, setColor] = useState('#3584e4');
2012
+
2013
+ <ColorPicker value={color} onChange={setColor} />
2014
+
2015
+ // Custom colors, with your own picker behind the "+"
2016
+ <ColorPicker
2017
+ value={color}
2018
+ onChange={setColor}
2019
+ allowCustom
2020
+ onRequestCustom={() => setPickerOpen(true)}
2021
+ />
2022
+ ```
2023
+
2024
+ Color palette picker following the Adwaita `GtkColorButton` + swatch pattern
2025
+ — mirrors `@gnome-ui/react`'s own `ColorPicker`. Renders a wrapping row of
2026
+ circular `ColorSwatch` items backed by a radio group, defaulting to
2027
+ `GNOME_PALETTE` (the 9 Adwaita named colors, the same set `Avatar` uses).
2028
+ `ColorSwatch` is exported for standalone use; sizes are **22 / 30 / 38 dp**.
2029
+
2030
+ **`allowCustom` is the one prop that changes meaning.** On the web it wires a
2031
+ hidden `<input type="color">` and the browser supplies the whole picker UI;
2032
+ RN has no such control, and an HSV picker is a component in its own right
2033
+ rather than a detail of this one. So the prop keeps its *visible* behaviour —
2034
+ the "+" button, and a `value` outside the palette shown as its own selected
2035
+ swatch — while the press is handed to a new **`onRequestCustom`** callback
2036
+ for your app to answer with whatever picker it has. The result round-trips
2037
+ through `value`/`onChange` exactly as before.
2038
+
2039
+ The web's three `box-shadow` rings collapse into real box-model pieces, since
2040
+ RN gives a `View` one border: the resting `inset 0 0 0 1px` hairline becomes
2041
+ `borderWidth: 1`, the selected `inset 0 0 0 2px rgb(255 255 255 / .9)`
2042
+ becomes a 2 dp white border, and the outer `0 0 0 2px var(--swatch-color)`
2043
+ becomes a wrapper painted in the swatch color. That wrapper is **always**
2044
+ rendered with the same 2 dp padding and only changes color: a box-shadow ring
2045
+ costs no layout space on the web while a real padded wrapper does, so
2046
+ reserving it unconditionally is what keeps the row from reflowing as the
2047
+ selection moves.
2048
+
2049
+ `filter: drop-shadow(...)` on the checkmark has no RN counterpart, so the
2050
+ path is drawn twice — a translucent black copy offset 1 dp down, then the
2051
+ white one on top. That's what the filter renders, and it's why it exists:
2052
+ without it the check disappears on the yellow swatch. The checkmark is
2053
+ hand-drawn with `react-native-svg` rather than taken from `@gnome-ui/icons`,
2054
+ mirroring the web version, which hand-draws it too — it's a stroked path, and
2055
+ `Icon`'s palette has no white to give it. The container is a `WrapBox`, and
2056
+ the "+" button's `border: 1.5px dashed` ports directly.
2057
+
2058
+ ### Bin
2059
+
2060
+ ```tsx
2061
+ import { Bin } from '@gnome-ui/react-native';
2062
+
2063
+ <Bin style={{ maxWidth: 480 }}>
2064
+ <ExpensiveChart data={series} />
2065
+ </Bin>
2066
+ ```
2067
+
2068
+ Single-child container with no visual styling — mirrors `AdwBin` and
2069
+ `@gnome-ui/react`'s own `Bin`. A transparent passthrough `View` forwarding
2070
+ every prop (and a ref to the underlying `View`) straight through, useful as
2071
+ a neutral base for custom components that need to apply layout or size
2072
+ constraints without introducing any chrome of their own. A plain RN `View`
2073
+ already has no default visual styling — no background, no border — so
2074
+ unlike the web port there's no CSS reset to strip; this is a pure
2075
+ passthrough.
2076
+
1433
2077
  ## Installation
1434
2078
 
1435
2079
  ```bash