@olympusoss/canvas 3.2.1 → 5.0.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 +75 -65
- package/package.json +11 -5
- package/src/atoms/avatar/avatar.md +185 -0
- package/src/atoms/avatar/avatar.styles.ts +48 -0
- package/src/atoms/avatar/avatar.tsx +99 -0
- package/src/atoms/badge/badge.md +237 -0
- package/src/atoms/badge/badge.styles.ts +79 -0
- package/src/atoms/badge/badge.tsx +86 -0
- package/src/atoms/breadcrumb/breadcrumb.md +233 -0
- package/src/atoms/breadcrumb/breadcrumb.styles.ts +40 -0
- package/src/atoms/breadcrumb/breadcrumb.tsx +130 -0
- package/src/atoms/button/button.android.tsx +6 -0
- package/src/atoms/button/button.ios.tsx +6 -0
- package/src/atoms/button/button.md +184 -0
- package/src/atoms/button/button.shared.tsx +79 -0
- package/src/atoms/button/button.styles.ts +152 -0
- package/src/atoms/button/button.tsx +6 -0
- package/src/atoms/button-group/button-group.android.tsx +6 -0
- package/src/atoms/button-group/button-group.ios.tsx +6 -0
- package/src/atoms/button-group/button-group.md +120 -0
- package/src/atoms/button-group/button-group.shared.tsx +398 -0
- package/src/atoms/button-group/button-group.styles.ts +483 -0
- package/src/atoms/button-group/button-group.tsx +6 -0
- package/src/atoms/checkbox/checkbox.android.tsx +6 -0
- package/src/atoms/checkbox/checkbox.ios.tsx +6 -0
- package/src/atoms/checkbox/checkbox.md +150 -0
- package/src/atoms/checkbox/checkbox.shared.tsx +103 -0
- package/src/atoms/checkbox/checkbox.styles.ts +106 -0
- package/src/atoms/checkbox/checkbox.tsx +6 -0
- package/src/atoms/combobox/combobox.android.tsx +6 -0
- package/src/atoms/combobox/combobox.ios.tsx +6 -0
- package/src/atoms/combobox/combobox.md +213 -0
- package/src/atoms/combobox/combobox.shared.tsx +160 -0
- package/src/atoms/combobox/combobox.styles.ts +270 -0
- package/src/atoms/combobox/combobox.tsx +6 -0
- package/src/atoms/divider/divider.md +140 -0
- package/src/atoms/divider/divider.styles.ts +35 -0
- package/src/atoms/divider/divider.tsx +67 -0
- package/src/atoms/dropdown/dropdown.android.tsx +6 -0
- package/src/atoms/dropdown/dropdown.ios.tsx +6 -0
- package/src/atoms/dropdown/dropdown.md +221 -0
- package/src/atoms/dropdown/dropdown.shared.tsx +190 -0
- package/src/atoms/dropdown/dropdown.styles.ts +233 -0
- package/src/atoms/dropdown/dropdown.tsx +6 -0
- package/src/atoms/icon/icon.md +131 -0
- package/src/atoms/icon/icon.styles.ts +30 -0
- package/src/atoms/icon/icon.tsx +328 -0
- package/src/atoms/index.ts +24 -0
- package/src/atoms/input/input.android.tsx +6 -0
- package/src/atoms/input/input.ios.tsx +6 -0
- package/src/atoms/input/input.md +118 -0
- package/src/atoms/input/input.shared.tsx +203 -0
- package/src/atoms/input/input.styles.ts +286 -0
- package/src/atoms/input/input.tsx +6 -0
- package/src/atoms/kbd/kbd.md +91 -0
- package/src/atoms/kbd/kbd.styles.ts +33 -0
- package/src/atoms/kbd/kbd.tsx +27 -0
- package/src/atoms/listbox/listbox.md +177 -0
- package/src/atoms/listbox/listbox.styles.ts +60 -0
- package/src/atoms/listbox/listbox.tsx +113 -0
- package/src/atoms/pagination/pagination.android.tsx +6 -0
- package/src/atoms/pagination/pagination.ios.tsx +6 -0
- package/src/atoms/pagination/pagination.md +133 -0
- package/src/atoms/pagination/pagination.shared.tsx +289 -0
- package/src/atoms/pagination/pagination.styles.ts +245 -0
- package/src/atoms/pagination/pagination.tsx +6 -0
- package/src/atoms/popover/popover.android.tsx +8 -0
- package/src/atoms/popover/popover.ios.tsx +6 -0
- package/src/atoms/popover/popover.md +87 -0
- package/src/atoms/popover/popover.shared.tsx +124 -0
- package/src/atoms/popover/popover.styles.ts +144 -0
- package/src/atoms/popover/popover.tsx +6 -0
- package/src/atoms/radio/radio.android.tsx +6 -0
- package/src/atoms/radio/radio.ios.tsx +6 -0
- package/src/atoms/radio/radio.md +173 -0
- package/src/atoms/radio/radio.shared.tsx +98 -0
- package/src/atoms/radio/radio.styles.ts +109 -0
- package/src/atoms/radio/radio.tsx +6 -0
- package/src/atoms/select/select.android.tsx +6 -0
- package/src/atoms/select/select.ios.tsx +6 -0
- package/src/atoms/select/select.md +156 -0
- package/src/atoms/select/select.shared.tsx +143 -0
- package/src/atoms/select/select.styles.ts +310 -0
- package/src/atoms/select/select.tsx +6 -0
- package/src/atoms/skeleton/skeleton.md +135 -0
- package/src/atoms/skeleton/skeleton.styles.ts +117 -0
- package/src/atoms/skeleton/skeleton.tsx +145 -0
- package/src/atoms/spinner/spinner.android.tsx +7 -0
- package/src/atoms/spinner/spinner.ios.tsx +7 -0
- package/src/atoms/spinner/spinner.md +94 -0
- package/src/atoms/spinner/spinner.shared.tsx +92 -0
- package/src/atoms/spinner/spinner.styles.tsx +115 -0
- package/src/atoms/spinner/spinner.tsx +7 -0
- package/src/atoms/switch/switch.android.tsx +6 -0
- package/src/atoms/switch/switch.ios.tsx +6 -0
- package/src/atoms/switch/switch.md +91 -0
- package/src/atoms/switch/switch.shared.tsx +97 -0
- package/src/atoms/switch/switch.styles.ts +79 -0
- package/src/atoms/switch/switch.tsx +6 -0
- package/src/atoms/textarea/textarea.android.tsx +6 -0
- package/src/atoms/textarea/textarea.ios.tsx +6 -0
- package/src/atoms/textarea/textarea.md +140 -0
- package/src/atoms/textarea/textarea.shared.tsx +74 -0
- package/src/atoms/textarea/textarea.styles.ts +116 -0
- package/src/atoms/textarea/textarea.tsx +6 -0
- package/src/atoms/tooltip/tooltip.android.tsx +6 -0
- package/src/atoms/tooltip/tooltip.ios.tsx +7 -0
- package/src/atoms/tooltip/tooltip.md +122 -0
- package/src/atoms/tooltip/tooltip.shared.tsx +113 -0
- package/src/atoms/tooltip/tooltip.styles.ts +113 -0
- package/src/atoms/tooltip/tooltip.tsx +6 -0
- package/src/atoms/typography/typography.md +330 -0
- package/src/atoms/typography/typography.styles.ts +95 -0
- package/src/atoms/typography/typography.tsx +76 -0
- package/src/index.ts +12 -2
- package/src/molecules/action-panels/action-panels.md +133 -0
- package/src/molecules/action-panels/action-panels.styles.ts +39 -0
- package/src/molecules/action-panels/action-panels.tsx +113 -0
- package/src/molecules/alert/alert.md +119 -0
- package/src/molecules/alert/alert.styles.ts +88 -0
- package/src/molecules/alert/alert.tsx +74 -0
- package/src/molecules/alert-dialog/alert-dialog.android.tsx +6 -0
- package/src/molecules/alert-dialog/alert-dialog.ios.tsx +6 -0
- package/src/molecules/alert-dialog/alert-dialog.md +177 -0
- package/src/molecules/alert-dialog/alert-dialog.shared.tsx +187 -0
- package/src/molecules/alert-dialog/alert-dialog.styles.ts +248 -0
- package/src/molecules/alert-dialog/alert-dialog.tsx +6 -0
- package/src/molecules/card/card.md +190 -0
- package/src/molecules/card/card.styles.ts +67 -0
- package/src/molecules/card/card.tsx +176 -0
- package/src/molecules/code-block/code-block.md +159 -0
- package/src/molecules/code-block/code-block.styles.ts +167 -0
- package/src/molecules/code-block/code-block.tsx +176 -0
- package/src/molecules/description-lists/description-lists.md +129 -0
- package/src/molecules/description-lists/description-lists.styles.ts +102 -0
- package/src/molecules/description-lists/description-lists.tsx +133 -0
- package/src/molecules/empty-state/empty-state.md +218 -0
- package/src/molecules/empty-state/empty-state.styles.ts +63 -0
- package/src/molecules/empty-state/empty-state.tsx +77 -0
- package/src/molecules/feeds/feeds.md +102 -0
- package/src/molecules/feeds/feeds.styles.ts +120 -0
- package/src/molecules/feeds/feeds.tsx +167 -0
- package/src/molecules/field/field.md +117 -0
- package/src/molecules/field/field.styles.ts +85 -0
- package/src/molecules/field/field.tsx +175 -0
- package/src/molecules/fieldset/fieldset.md +141 -0
- package/src/molecules/fieldset/fieldset.styles.ts +79 -0
- package/src/molecules/fieldset/fieldset.tsx +182 -0
- package/src/molecules/form/form.md +137 -0
- package/src/molecules/form/form.styles.ts +39 -0
- package/src/molecules/form/form.tsx +246 -0
- package/src/molecules/grid-lists/grid-lists.md +114 -0
- package/src/molecules/grid-lists/grid-lists.styles.ts +79 -0
- package/src/molecules/grid-lists/grid-lists.tsx +157 -0
- package/src/molecules/index.ts +16 -0
- package/src/molecules/media-objects/media-objects.md +87 -0
- package/src/molecules/media-objects/media-objects.styles.ts +94 -0
- package/src/molecules/media-objects/media-objects.tsx +128 -0
- package/src/molecules/stacked-lists/stacked-lists.md +116 -0
- package/src/molecules/stacked-lists/stacked-lists.styles.ts +111 -0
- package/src/molecules/stacked-lists/stacked-lists.tsx +195 -0
- package/src/molecules/stats/stats.md +166 -0
- package/src/molecules/stats/stats.styles.ts +91 -0
- package/src/molecules/stats/stats.tsx +88 -0
- package/src/organisms/calendar/calendar.android.tsx +6 -0
- package/src/organisms/calendar/calendar.ios.tsx +6 -0
- package/src/organisms/calendar/calendar.md +114 -0
- package/src/organisms/calendar/calendar.shared.tsx +146 -0
- package/src/organisms/calendar/calendar.styles.ts +315 -0
- package/src/organisms/calendar/calendar.tsx +6 -0
- package/src/organisms/charts/charts.md +326 -0
- package/src/organisms/charts/charts.styles.ts +135 -0
- package/src/organisms/charts/charts.tsx +124 -0
- package/src/organisms/command/command.md +117 -0
- package/src/organisms/command/command.styles.ts +179 -0
- package/src/organisms/command/command.tsx +164 -0
- package/src/organisms/data-table/data-table.md +182 -0
- package/src/organisms/data-table/data-table.styles.ts +103 -0
- package/src/organisms/data-table/data-table.tsx +105 -0
- package/src/organisms/dialog/dialog.android.tsx +6 -0
- package/src/organisms/dialog/dialog.ios.tsx +6 -0
- package/src/organisms/dialog/dialog.md +271 -0
- package/src/organisms/dialog/dialog.shared.tsx +230 -0
- package/src/organisms/dialog/dialog.styles.ts +272 -0
- package/src/organisms/dialog/dialog.tsx +6 -0
- package/src/organisms/filter-panel/filter-panel.md +116 -0
- package/src/organisms/filter-panel/filter-panel.styles.ts +83 -0
- package/src/organisms/filter-panel/filter-panel.tsx +91 -0
- package/src/organisms/index.ts +13 -0
- package/src/organisms/navbars/navbars.android.tsx +6 -0
- package/src/organisms/navbars/navbars.ios.tsx +6 -0
- package/src/organisms/navbars/navbars.md +144 -0
- package/src/organisms/navbars/navbars.shared.tsx +137 -0
- package/src/organisms/navbars/navbars.styles.ts +251 -0
- package/src/organisms/navbars/navbars.tsx +6 -0
- package/src/organisms/overlays/overlays.android.tsx +6 -0
- package/src/organisms/overlays/overlays.ios.tsx +6 -0
- package/src/organisms/overlays/overlays.md +123 -0
- package/src/organisms/overlays/overlays.shared.tsx +175 -0
- package/src/organisms/overlays/overlays.styles.ts +309 -0
- package/src/organisms/overlays/overlays.tsx +6 -0
- package/src/organisms/row-menu/row-menu.android.tsx +6 -0
- package/src/organisms/row-menu/row-menu.ios.tsx +6 -0
- package/src/organisms/row-menu/row-menu.md +102 -0
- package/src/organisms/row-menu/row-menu.shared.tsx +105 -0
- package/src/organisms/row-menu/row-menu.styles.ts +262 -0
- package/src/organisms/row-menu/row-menu.tsx +6 -0
- package/src/organisms/sidebar/sidebar.android.tsx +6 -0
- package/src/organisms/sidebar/sidebar.ios.tsx +6 -0
- package/src/organisms/sidebar/sidebar.md +188 -0
- package/src/organisms/sidebar/sidebar.shared.tsx +167 -0
- package/src/organisms/sidebar/sidebar.styles.ts +262 -0
- package/src/organisms/sidebar/sidebar.tsx +6 -0
- package/src/organisms/stepper/stepper.android.tsx +6 -0
- package/src/organisms/stepper/stepper.ios.tsx +6 -0
- package/src/organisms/stepper/stepper.md +150 -0
- package/src/organisms/stepper/stepper.shared.tsx +158 -0
- package/src/organisms/stepper/stepper.styles.ts +280 -0
- package/src/organisms/stepper/stepper.tsx +6 -0
- package/src/organisms/tabs/tabs.android.tsx +6 -0
- package/src/organisms/tabs/tabs.ios.tsx +6 -0
- package/src/organisms/tabs/tabs.md +127 -0
- package/src/organisms/tabs/tabs.shared.tsx +281 -0
- package/src/organisms/tabs/tabs.styles.ts +398 -0
- package/src/organisms/tabs/tabs.tsx +6 -0
- package/src/style/color.ts +17 -0
- package/src/style/index.ts +14 -0
- package/src/style/primitives.ts +26 -0
- package/src/style/responsive.ts +45 -0
- package/src/style/shadow.ts +21 -0
- package/src/style/theme.tsx +56 -0
- package/src/style/tokens.ts +487 -0
- package/styles/canvas.css +127 -74
- package/tsconfig.json +4 -2
- package/src/cn.ts +0 -3
- package/styles/atoms/avatar.css +0 -22
- package/styles/atoms/badge.css +0 -83
- package/styles/atoms/breadcrumb.css +0 -35
- package/styles/atoms/button-group.css +0 -23
- package/styles/atoms/button.css +0 -107
- package/styles/atoms/checkbox.css +0 -55
- package/styles/atoms/combobox.css +0 -76
- package/styles/atoms/dropdown.css +0 -54
- package/styles/atoms/icon.css +0 -8
- package/styles/atoms/input-group.css +0 -45
- package/styles/atoms/input.css +0 -56
- package/styles/atoms/kbd.css +0 -15
- package/styles/atoms/pagination.css +0 -48
- package/styles/atoms/popover.css +0 -14
- package/styles/atoms/radio.css +0 -28
- package/styles/atoms/select.css +0 -57
- package/styles/atoms/separator.css +0 -32
- package/styles/atoms/skeleton.css +0 -32
- package/styles/atoms/spinner.css +0 -26
- package/styles/atoms/switch.css +0 -45
- package/styles/atoms/textarea.css +0 -31
- package/styles/atoms/tooltip.css +0 -53
- package/styles/atoms/typography.css +0 -105
- package/styles/base.css +0 -17
- package/styles/molecules/alert.css +0 -66
- package/styles/molecules/card.css +0 -58
- package/styles/molecules/code-block.css +0 -18
- package/styles/molecules/empty-state.css +0 -17
- package/styles/molecules/field.css +0 -27
- package/styles/molecules/form.css +0 -27
- package/styles/molecules/page-header.css +0 -52
- package/styles/molecules/section-card.css +0 -49
- package/styles/molecules/stat-card.css +0 -71
- package/styles/molecules/toast.css +0 -95
- package/styles/organisms/app-shell.css +0 -46
- package/styles/organisms/calendar.css +0 -73
- package/styles/organisms/command.css +0 -95
- package/styles/organisms/data-table.css +0 -142
- package/styles/organisms/dialog.css +0 -72
- package/styles/organisms/filter-panel.css +0 -58
- package/styles/organisms/row-menu.css +0 -69
- package/styles/organisms/sheet.css +0 -70
- package/styles/organisms/sidebar.css +0 -146
- package/styles/organisms/stepper.css +0 -63
- package/styles/organisms/tabs.css +0 -40
- package/styles/organisms/topbar.css +0 -24
- package/styles/patterns/backdrops.css +0 -35
- package/styles/patterns/density.css +0 -66
- package/styles/patterns/focus.css +0 -22
- package/styles/patterns/glass.css +0 -85
- package/styles/patterns/high-contrast.css +0 -70
- package/styles/patterns/reduced-motion.css +0 -12
- package/styles/patterns/scrollbar.css +0 -10
- package/styles/reset.css +0 -89
- package/styles/tokens/colors.css +0 -108
- package/styles/tokens/motion.css +0 -33
- package/styles/tokens/radius.css +0 -10
- package/styles/tokens/shadows.css +0 -35
- package/styles/tokens/spacing.css +0 -19
- package/styles/tokens/typography.css +0 -6
- package/styles/tokens/z-index.css +0 -12
- package/styles/utilities/display.css +0 -66
- package/styles/utilities/flexbox.css +0 -240
- package/styles/utilities/gap.css +0 -288
- package/styles/utilities/grid.css +0 -138
- package/styles/utilities/position.css +0 -78
- package/styles/utilities/sizing.css +0 -138
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View, Text, useTheme, type StyleProp, type ViewStyle } from "../../style/index.js";
|
|
3
|
+
import { Button } from "../button/button.js";
|
|
4
|
+
import { type PopoverSkin, type Placement } from "./popover.styles.js";
|
|
5
|
+
import * as s from "./popover.styles.js";
|
|
6
|
+
|
|
7
|
+
// Shared Popover shell. The structure (a trigger paired with a floating card of
|
|
8
|
+
// rich content — a heading, supporting text, and an optional action), the public
|
|
9
|
+
// boolean-prop API, the placement precedence, the controlled/uncontrolled open
|
|
10
|
+
// state, the open/close handlers, the inline (always-visible) mode, and the
|
|
11
|
+
// floating-vs-inline overlay behavior all live here once. A platform file
|
|
12
|
+
// supplies only its skin (card shape/fill/border, heading/description type, and
|
|
13
|
+
// whether an anchor arrow is drawn) and calls createPopover.
|
|
14
|
+
//
|
|
15
|
+
// The card is an overlay surface; in real use it floats above the page anchored
|
|
16
|
+
// to the trigger. Here it is rendered inline, directly below the trigger, so the
|
|
17
|
+
// open state is visible without a portal or Modal.
|
|
18
|
+
//
|
|
19
|
+
// Axes:
|
|
20
|
+
//
|
|
21
|
+
// - Placement (pick one; default is `bottom`): `top` > `bottom`. Presentational
|
|
22
|
+
// only in this inline rendering; it documents the intended anchor side and (on
|
|
23
|
+
// iOS) which edge the arrow rides.
|
|
24
|
+
//
|
|
25
|
+
// Content props (the children-less / data-driven case):
|
|
26
|
+
//
|
|
27
|
+
// - `trigger`: label for the trigger Button (rendered as <Button outline small>).
|
|
28
|
+
// - `title`: the popover heading.
|
|
29
|
+
// - `description`: the supporting line beneath the title.
|
|
30
|
+
// - `actionLabel`: when set, renders a <Button primary small> action row.
|
|
31
|
+
//
|
|
32
|
+
// State:
|
|
33
|
+
//
|
|
34
|
+
// - `open` (default false, uncontrolled via the trigger): when true, the floating
|
|
35
|
+
// card is shown below the trigger; when false, only the trigger renders.
|
|
36
|
+
|
|
37
|
+
export interface PopoverProps {
|
|
38
|
+
/** Label for the trigger button. */
|
|
39
|
+
trigger?: string;
|
|
40
|
+
/** Heading shown at the top of the floating card. */
|
|
41
|
+
title?: string;
|
|
42
|
+
/** Supporting line beneath the title. */
|
|
43
|
+
description?: string;
|
|
44
|
+
/** When set, renders a primary action button at the bottom of the card. */
|
|
45
|
+
actionLabel?: string;
|
|
46
|
+
// Placement (pick one; default is `bottom`). Presentational only here.
|
|
47
|
+
top?: boolean;
|
|
48
|
+
bottom?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Static mode: render the card on its own with no trigger button (an
|
|
51
|
+
* always-visible inline panel). When set, the card is always shown and
|
|
52
|
+
* `open` is ignored.
|
|
53
|
+
*/
|
|
54
|
+
inline?: boolean;
|
|
55
|
+
/** Controlled open state. Omit for uncontrolled (the trigger toggles it). */
|
|
56
|
+
open?: boolean;
|
|
57
|
+
/** Fired when the open state changes. */
|
|
58
|
+
onOpenChange?: (open: boolean) => void;
|
|
59
|
+
/** Escape hatch for layout/positioning composition (width, margins). */
|
|
60
|
+
style?: StyleProp<ViewStyle>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Placement precedence when more than one is passed: first match wins.
|
|
64
|
+
function placementOf(p: PopoverProps): Placement {
|
|
65
|
+
if (p.top) return "top";
|
|
66
|
+
return "bottom";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a Popover component from a platform skin. */
|
|
70
|
+
export function createPopover(skin: PopoverSkin) {
|
|
71
|
+
return function Popover(props: PopoverProps) {
|
|
72
|
+
const { trigger, title, description, actionLabel, inline, onOpenChange, style } = props;
|
|
73
|
+
const { tokens } = useTheme();
|
|
74
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
75
|
+
// In static (inline) mode the card is always shown; otherwise it is
|
|
76
|
+
// uncontrolled (the trigger toggles it) unless a controlled `open` is passed.
|
|
77
|
+
const open = inline ? true : (props.open ?? internalOpen);
|
|
78
|
+
const setOpen = (next: boolean) => {
|
|
79
|
+
if (props.open === undefined) setInternalOpen(next);
|
|
80
|
+
onOpenChange?.(next);
|
|
81
|
+
};
|
|
82
|
+
// Resolve the placement axis (documented, presentational in this rendering;
|
|
83
|
+
// on iOS it also picks the edge the arrow rides).
|
|
84
|
+
const placement = placementOf(props);
|
|
85
|
+
|
|
86
|
+
// With a trigger, the card floats (absolute) below it (the wrapper is
|
|
87
|
+
// `relative`), so it overflows its container instead of growing it. In
|
|
88
|
+
// inline mode it is a standalone in-flow panel. The `style` escape hatch
|
|
89
|
+
// applies last.
|
|
90
|
+
const card: StyleProp<ViewStyle> = [
|
|
91
|
+
skin.card(tokens),
|
|
92
|
+
!inline ? s.cardFloating : null,
|
|
93
|
+
style,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<View style={[inline ? null : s.wrapper, !inline && open ? s.wrapperLifted : null]}>
|
|
98
|
+
{inline ? null : (
|
|
99
|
+
<View style={s.triggerWrap}>
|
|
100
|
+
<Button outline small onPress={() => setOpen(!open)}>
|
|
101
|
+
{trigger ?? "Open popover"}
|
|
102
|
+
</Button>
|
|
103
|
+
</View>
|
|
104
|
+
)}
|
|
105
|
+
{open ? (
|
|
106
|
+
<View style={card}>
|
|
107
|
+
{/* The anchor arrow: drawn only when the skin supplies one (iOS) and
|
|
108
|
+
only in the floating case (an inline panel has no anchor). */}
|
|
109
|
+
{skin.arrow != null && !inline ? <View style={skin.arrow(tokens, placement)} /> : null}
|
|
110
|
+
{title != null ? <Text style={skin.title(tokens)}>{title}</Text> : null}
|
|
111
|
+
{description != null ? <Text style={skin.description(tokens)}>{description}</Text> : null}
|
|
112
|
+
{actionLabel != null ? (
|
|
113
|
+
<View style={s.actionRow}>
|
|
114
|
+
<Button primary small onPress={() => setOpen(false)}>
|
|
115
|
+
{actionLabel}
|
|
116
|
+
</Button>
|
|
117
|
+
</View>
|
|
118
|
+
) : null}
|
|
119
|
+
</View>
|
|
120
|
+
) : null}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type ViewStyle, type TextStyle } from "react-native";
|
|
2
|
+
import { type ColorTokens, shadow } from "../../style/index.js";
|
|
3
|
+
|
|
4
|
+
// Co-located Popover skins, one per platform, all driven by the brand tokens
|
|
5
|
+
// (passed in from useTheme so they follow light/dark and read as glass when the
|
|
6
|
+
// ThemeProvider's surface is "glass", since tokens.popover is swapped translucent
|
|
7
|
+
// at the theming level). The BRAND survives on every platform (the heading type
|
|
8
|
+
// and the primary action button stay the indigo brand, never a platform default);
|
|
9
|
+
// only the native SHAPE, fill, border treatment, elevation, and padding change
|
|
10
|
+
// per OS:
|
|
11
|
+
// iOS 27 (iOS 26+, Liquid Glass) popover: a largely rounded card (~26 radius)
|
|
12
|
+
// over the `popover` material, NO visible border, a soft lg shadow, ~16pt
|
|
13
|
+
// padding, with a small soft ARROW pointing toward the anchor (up when the
|
|
14
|
+
// card is below the trigger, down when above). The selection accent / action
|
|
15
|
+
// button stay the brand indigo.
|
|
16
|
+
// Android (no native popover): a flat-cornered ELEVATED surface (~12 radius)
|
|
17
|
+
// over `popover`, M3 elevation (md shadow), NO border and NO arrow — an
|
|
18
|
+
// elevated menu/dialog-style surface, mirroring the select Android menu.
|
|
19
|
+
// Web: the established Canvas look (the current popover, lifted verbatim) — a
|
|
20
|
+
// fixed 260px card, 8 radius, a full 1px `border`, `popover` fill, 16 padding,
|
|
21
|
+
// shadow-lg; no arrow.
|
|
22
|
+
|
|
23
|
+
export type Placement = "top" | "bottom";
|
|
24
|
+
|
|
25
|
+
// The contract a platform skin fulfills. The shell resolves the placement axis
|
|
26
|
+
// and the inline/floating state and passes them in; the skin maps them to RN
|
|
27
|
+
// style objects. `arrow` returns the small pointer's ViewStyle (positioned by the
|
|
28
|
+
// shell toward the anchor) or null when the platform draws no arrow.
|
|
29
|
+
export interface PopoverSkin {
|
|
30
|
+
/** The floating card frame: width, radius, border, fill, padding, shadow. */
|
|
31
|
+
card: (t: ColorTokens) => ViewStyle;
|
|
32
|
+
/** The popover heading. */
|
|
33
|
+
title: (t: ColorTokens) => TextStyle;
|
|
34
|
+
/** The supporting line beneath the title. */
|
|
35
|
+
description: (t: ColorTokens) => TextStyle;
|
|
36
|
+
/** The arrow pointing toward the anchor, or null when the platform has none. */
|
|
37
|
+
arrow: ((t: ColorTokens, placement: Placement) => ViewStyle) | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- shared layout fragments (identical across platforms) -------------------
|
|
41
|
+
|
|
42
|
+
// The outer wrapper when a trigger is present: it anchors the absolutely
|
|
43
|
+
// positioned card (`relative`) and hugs its content (`self-start`).
|
|
44
|
+
export const wrapper: ViewStyle = { position: "relative", alignSelf: "flex-start" };
|
|
45
|
+
|
|
46
|
+
// When the popover is open, the wrapper itself is lifted into its own stacking
|
|
47
|
+
// context above sibling content. react-native-web gives every positioned View an
|
|
48
|
+
// implicit stacking context, so the card's own `zIndex` is scoped INSIDE the
|
|
49
|
+
// `relative` wrapper and cannot rise above a later sibling (e.g. the next
|
|
50
|
+
// platform row in the docs preview, or any following element on a real page).
|
|
51
|
+
// Raising the wrapper's zIndex while open lifts the whole overlay — trigger and
|
|
52
|
+
// card together — above everything painted after it.
|
|
53
|
+
export const wrapperLifted: ViewStyle = { zIndex: 50 };
|
|
54
|
+
|
|
55
|
+
// The trigger button is wrapped so it hugs its content rather than stretching.
|
|
56
|
+
export const triggerWrap: ViewStyle = { alignSelf: "flex-start" };
|
|
57
|
+
|
|
58
|
+
// With a trigger, the card floats below it (the wrapper is `relative`): pinned
|
|
59
|
+
// to the wrapper's bottom-left, lifted above siblings, with a small gap.
|
|
60
|
+
export const cardFloating: ViewStyle = {
|
|
61
|
+
position: "absolute",
|
|
62
|
+
top: "100%",
|
|
63
|
+
left: 0,
|
|
64
|
+
zIndex: 50,
|
|
65
|
+
marginTop: 8,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The action row: a right-aligned button, spaced from the body above it.
|
|
69
|
+
export const actionRow: ViewStyle = { marginTop: 12, flexDirection: "row", justifyContent: "flex-end" };
|
|
70
|
+
|
|
71
|
+
// The card heading + description share this brand type scale across platforms
|
|
72
|
+
// (small, the brand face, not a platform-specific font).
|
|
73
|
+
const TITLE_TYPE: TextStyle = { fontSize: 14, lineHeight: 20, fontWeight: "600" };
|
|
74
|
+
const DESC_TYPE: TextStyle = { marginTop: 4, fontSize: 14, lineHeight: 20 };
|
|
75
|
+
|
|
76
|
+
// ---------- Web: the established Canvas look (lifted verbatim) ----------
|
|
77
|
+
// A fixed 260px card, the menu radius (8), a full 1px `border`, the `popover`
|
|
78
|
+
// fill (translucent under glass), 16 padding, shadow-lg; no arrow.
|
|
79
|
+
export const webSkin: PopoverSkin = {
|
|
80
|
+
card: (t) => ({
|
|
81
|
+
width: 260,
|
|
82
|
+
borderRadius: 8,
|
|
83
|
+
borderWidth: 1,
|
|
84
|
+
borderColor: t.border,
|
|
85
|
+
backgroundColor: t.popover,
|
|
86
|
+
padding: 16,
|
|
87
|
+
...shadow("lg"),
|
|
88
|
+
}),
|
|
89
|
+
title: (t) => ({ ...TITLE_TYPE, color: t["popover-foreground"] }),
|
|
90
|
+
description: (t) => ({ ...DESC_TYPE, color: t["muted-foreground"] }),
|
|
91
|
+
arrow: null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ---------- iOS 27 (Liquid Glass popover): rounded material card, no border, arrow ----------
|
|
95
|
+
// Apple's iOS 26+/Liquid Glass popover: a largely rounded rect (~26pt) over the
|
|
96
|
+
// `popover` material with NO visible border, a soft lg drop shadow, ~16pt
|
|
97
|
+
// padding, and a small soft arrow pointing toward the anchor view. Brand
|
|
98
|
+
// type/accents survive.
|
|
99
|
+
const IOS_ARROW = 9; // half-side of the rotated square that forms the soft pointer
|
|
100
|
+
export const iosSkin: PopoverSkin = {
|
|
101
|
+
card: (t) => ({
|
|
102
|
+
width: 260,
|
|
103
|
+
borderRadius: 26,
|
|
104
|
+
backgroundColor: t.popover,
|
|
105
|
+
padding: 16,
|
|
106
|
+
...shadow("lg"),
|
|
107
|
+
}),
|
|
108
|
+
title: (t) => ({ ...TITLE_TYPE, color: t["popover-foreground"] }),
|
|
109
|
+
description: (t) => ({ ...DESC_TYPE, color: t["muted-foreground"] }),
|
|
110
|
+
// A square rotated 45° reads as a diamond; clipped by the card edge it shows as
|
|
111
|
+
// a pointer. Its corners are softly rounded so the exposed tip reads as the
|
|
112
|
+
// Liquid Glass nub (a soft bump) rather than a hard triangle. Positioned by the
|
|
113
|
+
// shell flush to the card's anchor-facing edge, offset in from the left so it
|
|
114
|
+
// sits under the trigger.
|
|
115
|
+
arrow: (t, placement) => ({
|
|
116
|
+
position: "absolute",
|
|
117
|
+
left: 22,
|
|
118
|
+
width: IOS_ARROW * 2,
|
|
119
|
+
height: IOS_ARROW * 2,
|
|
120
|
+
backgroundColor: t.popover,
|
|
121
|
+
borderRadius: 5,
|
|
122
|
+
transform: [{ rotate: "45deg" }],
|
|
123
|
+
// Card is below the trigger (`bottom`): pointer rides the TOP edge pointing
|
|
124
|
+
// up. For `top` (card above) it rides the BOTTOM edge pointing down.
|
|
125
|
+
...(placement === "top" ? { bottom: -IOS_ARROW } : { top: -IOS_ARROW }),
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ---------- Android (no native popover): flat-cornered elevated surface ----------
|
|
130
|
+
// Material 3 has no popover; the convention is an elevated menu/dialog-style
|
|
131
|
+
// surface. A flat-cornered card (~12dp radius) over `popover` with M3 elevation
|
|
132
|
+
// (md shadow), NO border and NO arrow — mirrors the select Android menu surface.
|
|
133
|
+
export const androidSkin: PopoverSkin = {
|
|
134
|
+
card: (t) => ({
|
|
135
|
+
width: 260,
|
|
136
|
+
borderRadius: 12,
|
|
137
|
+
backgroundColor: t.popover,
|
|
138
|
+
padding: 16,
|
|
139
|
+
...shadow("md"),
|
|
140
|
+
}),
|
|
141
|
+
title: (t) => ({ ...TITLE_TYPE, color: t["popover-foreground"] }),
|
|
142
|
+
description: (t) => ({ ...DESC_TYPE, color: t["muted-foreground"] }),
|
|
143
|
+
arrow: null,
|
|
144
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createPopover } from "./popover.shared.js";
|
|
2
|
+
import { webSkin } from "./popover.styles.js";
|
|
3
|
+
|
|
4
|
+
// Web Popover (the base; Metro falls back to it on native, web bundlers resolve it).
|
|
5
|
+
export const Popover = createPopover(webSkin);
|
|
6
|
+
export type { PopoverProps } from "./popover.shared.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createRadio } from "./radio.shared.js";
|
|
2
|
+
import { androidSkin } from "./radio.styles.js";
|
|
3
|
+
|
|
4
|
+
// Material 3 Radio. Metro resolves this file on Android; the docs import it for preview.
|
|
5
|
+
export const Radio = createRadio(androidSkin);
|
|
6
|
+
export type { RadioProps } from "./radio.shared.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createRadio } from "./radio.shared.js";
|
|
2
|
+
import { iosSkin } from "./radio.styles.js";
|
|
3
|
+
|
|
4
|
+
// iOS (HIG) Radio. Metro resolves this file on iOS; the docs import it for preview.
|
|
5
|
+
export const Radio = createRadio(iosSkin);
|
|
6
|
+
export type { RadioProps } from "./radio.shared.js";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Radios
|
|
2
|
+
|
|
3
|
+
Single-pick selection: stacked, inline, card-style.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<View style={{ flexDirection: "column", gap: 10 }}>
|
|
9
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
10
|
+
<Radio style={{ marginTop: 3 }} />
|
|
11
|
+
<View>
|
|
12
|
+
<Text style={{ fontSize: 13, fontWeight: "500", color: tokens.foreground }}>Hobby</Text>
|
|
13
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For personal projects and experiments.</Text>
|
|
14
|
+
</View>
|
|
15
|
+
</View>
|
|
16
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
17
|
+
<Radio checked style={{ marginTop: 3 }} />
|
|
18
|
+
<View>
|
|
19
|
+
<Text style={{ fontSize: 13, fontWeight: "500", color: tokens.foreground }}>Pro</Text>
|
|
20
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams that need more control.</Text>
|
|
21
|
+
</View>
|
|
22
|
+
</View>
|
|
23
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
24
|
+
<Radio style={{ marginTop: 3 }} />
|
|
25
|
+
<View>
|
|
26
|
+
<Text style={{ fontSize: 13, fontWeight: "500", color: tokens.foreground }}>Enterprise</Text>
|
|
27
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>Advanced security, compliance, and support.</Text>
|
|
28
|
+
</View>
|
|
29
|
+
</View>
|
|
30
|
+
</View>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Variants
|
|
34
|
+
|
|
35
|
+
### Variant - inline
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
<Radio checked small>Pro, for growing teams that need more control.</Radio>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Variant - card
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
45
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, padding: 14, borderWidth: 1, borderColor: tokens.border }}>
|
|
46
|
+
<Radio style={{ marginBottom: 8 }} />
|
|
47
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Hobby</Text>
|
|
48
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For personal projects and experiments.</Text>
|
|
49
|
+
</View>
|
|
50
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, padding: 14, borderWidth: 2, borderColor: tokens.primary, backgroundColor: alpha(tokens.primary, 0.05) }}>
|
|
51
|
+
<Radio checked style={{ marginBottom: 8 }} />
|
|
52
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Pro</Text>
|
|
53
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams that need more control.</Text>
|
|
54
|
+
</View>
|
|
55
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, padding: 14, borderWidth: 1, borderColor: tokens.border }}>
|
|
56
|
+
<Radio style={{ marginBottom: 8 }} />
|
|
57
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Enterprise</Text>
|
|
58
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>Advanced security, compliance, and support.</Text>
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Do & Don't
|
|
64
|
+
|
|
65
|
+
**Do** — Pre-select a sensible default so the common path needs no clicks.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
<View style={{ flexDirection: "column", gap: 8 }}>
|
|
69
|
+
<Text style={{ marginBottom: 4, fontSize: 14, lineHeight: 20, fontWeight: "600", color: tokens.foreground }}>Plan</Text>
|
|
70
|
+
<Radio>Hobby</Radio>
|
|
71
|
+
<Radio checked>Pro</Radio>
|
|
72
|
+
<Radio>Enterprise</Radio>
|
|
73
|
+
</View>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Don't** — Leaving a radio group with nothing selected forces an extra decision and can submit empty.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
<View style={{ flexDirection: "column", gap: 8 }}>
|
|
80
|
+
<Text style={{ marginBottom: 4, fontSize: 14, lineHeight: 20, fontWeight: "600", color: tokens.foreground }}>Plan</Text>
|
|
81
|
+
<Radio>Hobby</Radio>
|
|
82
|
+
<Radio>Pro</Radio>
|
|
83
|
+
<Radio>Enterprise</Radio>
|
|
84
|
+
</View>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Stacked
|
|
88
|
+
|
|
89
|
+
**Do** — Align the control to the first text line (mt-[3px]) so it sits beside the title, with the description flowing below.
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
<View style={{ flexDirection: "column", gap: 10 }}>
|
|
93
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
94
|
+
<Radio checked style={{ marginTop: 3 }} />
|
|
95
|
+
<View>
|
|
96
|
+
<Text style={{ fontSize: 13, fontWeight: "500", color: tokens.foreground }}>Pro</Text>
|
|
97
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams that need more control.</Text>
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
</View>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Don't** — With items-center the input floats to the vertical middle of a two-line label, leaving it visually unattached to the title it controls.
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
<View style={{ flexDirection: "column", gap: 10 }}>
|
|
107
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
108
|
+
<Radio checked />
|
|
109
|
+
<View>
|
|
110
|
+
<Text style={{ fontSize: 13, fontWeight: "500", color: tokens.foreground }}>Pro</Text>
|
|
111
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams that need more control.</Text>
|
|
112
|
+
</View>
|
|
113
|
+
</View>
|
|
114
|
+
</View>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Inline
|
|
118
|
+
|
|
119
|
+
**Do** — Use gap-6 between options (gap-2 inside each) so every label clearly pairs with its own control.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 24 }}>
|
|
123
|
+
<Radio checked small>Hobby</Radio>
|
|
124
|
+
<Radio small>Pro</Radio>
|
|
125
|
+
<Radio small>Enterprise</Radio>
|
|
126
|
+
</View>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Don't** — Cramped gap-1 between options makes each label blur into the next radio, so it is hard to tell which dot belongs to which choice.
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
|
|
133
|
+
<Radio checked small>Hobby</Radio>
|
|
134
|
+
<Radio small>Pro</Radio>
|
|
135
|
+
<Radio small>Enterprise</Radio>
|
|
136
|
+
</View>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Card
|
|
140
|
+
|
|
141
|
+
**Do** — Give the selected card a primary border and tinted fill so the whole tile reads as chosen, not just the dot.
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
145
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, borderWidth: 2, borderColor: tokens.primary, backgroundColor: alpha(tokens.primary, 0.05), padding: 14 }}>
|
|
146
|
+
<Radio checked style={{ marginBottom: 8 }} />
|
|
147
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Pro</Text>
|
|
148
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams.</Text>
|
|
149
|
+
</View>
|
|
150
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, borderWidth: 1, borderColor: tokens.border, padding: 14 }}>
|
|
151
|
+
<Radio style={{ marginBottom: 8 }} />
|
|
152
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Enterprise</Text>
|
|
153
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>Advanced security.</Text>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Don't** — When the selected card keeps the same plain border, only the tiny native dot signals the choice and the active card is easy to miss.
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
162
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, borderWidth: 1, borderColor: tokens.border, padding: 14 }}>
|
|
163
|
+
<Radio checked style={{ marginBottom: 8 }} />
|
|
164
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Pro</Text>
|
|
165
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>For growing teams.</Text>
|
|
166
|
+
</View>
|
|
167
|
+
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: "0%", flexDirection: "column", borderRadius: 6, borderWidth: 1, borderColor: tokens.border, padding: 14 }}>
|
|
168
|
+
<Radio style={{ marginBottom: 8 }} />
|
|
169
|
+
<Text style={{ fontSize: 13, fontWeight: "600", color: tokens.foreground }}>Enterprise</Text>
|
|
170
|
+
<Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>Advanced security.</Text>
|
|
171
|
+
</View>
|
|
172
|
+
</View>
|
|
173
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type GestureResponderEvent } from "react-native";
|
|
3
|
+
import { View, Pressable, Text, useTheme, type ColorTokens, type StyleProp, type ViewStyle, type TextStyle } from "../../style/index.js";
|
|
4
|
+
|
|
5
|
+
// Shared Radio shell. Uses React Native's primitives DIRECTLY and reads the active
|
|
6
|
+
// brand tokens via useTheme, so colors follow light/dark and the glass surface. The
|
|
7
|
+
// shared structure (the ring + dot + label row, the size precedence, accessibility,
|
|
8
|
+
// the controlled checked/selected alias, the onChange handler) lives here once; a
|
|
9
|
+
// platform file supplies only its skin (ring shape/sizing/border, dot fill, press
|
|
10
|
+
// feedback) and calls createRadio. iOS has no native radio, so every skin is
|
|
11
|
+
// hand-drawn from the brand tokens — no platform default color ever leaks in.
|
|
12
|
+
//
|
|
13
|
+
// A radio is a single circular control that fills with a centered dot when it is the
|
|
14
|
+
// chosen option in its group. The group (one selected option at a time) is the
|
|
15
|
+
// caller's job; this component renders one control in the state it is told to
|
|
16
|
+
// (controlled). Selecting swaps the ring from the neutral input border to primary
|
|
17
|
+
// and reveals the primary dot.
|
|
18
|
+
|
|
19
|
+
export interface RadioProps {
|
|
20
|
+
/** Whether this control is the selected option (controlled). */
|
|
21
|
+
checked?: boolean;
|
|
22
|
+
/** Alias for `checked`, for callers that think in terms of "selected". */
|
|
23
|
+
selected?: boolean;
|
|
24
|
+
/** Fired on press with the next checked value (always true for a radio). */
|
|
25
|
+
onChange?: (checked: boolean, event: GestureResponderEvent) => void;
|
|
26
|
+
/** Label text shown beside the control. */
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
// Size (pick one; default is the 16px control).
|
|
29
|
+
small?: boolean;
|
|
30
|
+
large?: boolean;
|
|
31
|
+
/** Dim the control and block presses. */
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
/** Escape hatch for layout/positioning composition. */
|
|
34
|
+
style?: StyleProp<ViewStyle>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Size = "small" | "default" | "large";
|
|
38
|
+
|
|
39
|
+
// Size precedence when more than one is passed: first match wins.
|
|
40
|
+
function sizeOf(p: RadioProps): Size {
|
|
41
|
+
if (p.small) return "small";
|
|
42
|
+
if (p.large) return "large";
|
|
43
|
+
return "default";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The only thing a platform skin owns: the ring, dot, and label styles for a given
|
|
47
|
+
// state and size, plus the press/disabled feedback. Everything else is the shell.
|
|
48
|
+
export interface RadioSkin {
|
|
49
|
+
/** The circular control. `checked` swaps the neutral border for primary. `nudge` aligns the ring to a label's first line. */
|
|
50
|
+
ring: (tokens: ColorTokens, size: Size, checked: boolean, nudge: boolean) => ViewStyle;
|
|
51
|
+
/** The centered fill dot, rendered only when checked. */
|
|
52
|
+
dot: (tokens: ColorTokens, size: Size) => ViewStyle;
|
|
53
|
+
/** The label text to the right of the ring. */
|
|
54
|
+
label: (tokens: ColorTokens, size: Size, disabled: boolean) => TextStyle;
|
|
55
|
+
/** Opacity applied to the row when disabled. */
|
|
56
|
+
disabledOpacity: number;
|
|
57
|
+
/** iOS/web dim the row on press; Android uses a ripple instead (null). */
|
|
58
|
+
pressedOpacity: number | null;
|
|
59
|
+
/** Android ripple over the ring; null on iOS/web. */
|
|
60
|
+
ripple: ((tokens: ColorTokens) => { color: string; borderless: boolean; radius?: number }) | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The pressable row: control beside an optional label, control top-aligned so a
|
|
64
|
+
// multi-line label hangs from the ring's first line.
|
|
65
|
+
const ROW: ViewStyle = { flexDirection: "row", alignItems: "flex-start", gap: 8 };
|
|
66
|
+
|
|
67
|
+
/** Build a Radio component from a platform skin. */
|
|
68
|
+
export function createRadio(skin: RadioSkin) {
|
|
69
|
+
return function Radio(props: RadioProps) {
|
|
70
|
+
const { checked, selected, onChange, children, disabled, style } = props;
|
|
71
|
+
const isChecked = !!(checked ?? selected);
|
|
72
|
+
const size = sizeOf(props);
|
|
73
|
+
const { tokens } = useTheme();
|
|
74
|
+
|
|
75
|
+
const ripple = skin.ripple ? skin.ripple(tokens) : undefined;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Pressable
|
|
79
|
+
onPress={(event) => onChange?.(true, event)}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
accessibilityRole="radio"
|
|
82
|
+
accessibilityState={{ checked: isChecked, disabled: !!disabled }}
|
|
83
|
+
android_ripple={ripple}
|
|
84
|
+
style={({ pressed }) => [
|
|
85
|
+
ROW,
|
|
86
|
+
disabled ? { opacity: skin.disabledOpacity } : null,
|
|
87
|
+
skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null,
|
|
88
|
+
style,
|
|
89
|
+
]}
|
|
90
|
+
>
|
|
91
|
+
<View style={skin.ring(tokens, size, isChecked, children != null)}>
|
|
92
|
+
{isChecked ? <View style={skin.dot(tokens, size)} /> : null}
|
|
93
|
+
</View>
|
|
94
|
+
{children != null ? <Text style={skin.label(tokens, size, !!disabled)}>{children}</Text> : null}
|
|
95
|
+
</Pressable>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type ViewStyle, type TextStyle } from "react-native";
|
|
2
|
+
import { type ColorTokens } from "../../style/index.js";
|
|
3
|
+
import { type RadioSkin, type Size } from "./radio.shared.js";
|
|
4
|
+
|
|
5
|
+
// Co-located Radio skins, one per platform, all driven by the brand tokens (passed
|
|
6
|
+
// in from useTheme so they follow light/dark and the glass surface). The BRAND
|
|
7
|
+
// survives on every platform (the selected ring + dot are always the indigo
|
|
8
|
+
// `primary`, never a platform default), and only the native SHAPE, sizing, border
|
|
9
|
+
// weight, and press feedback change per OS:
|
|
10
|
+
// iOS (HIG): no native radio; the de-facto ring + filled center dot — a ~20pt
|
|
11
|
+
// circle, a 1.5px border when empty, brand ring + ~40% inner dot when selected,
|
|
12
|
+
// instant; press = opacity dim (~0.8).
|
|
13
|
+
// Android (Material 3): a 20dp outer ring (2dp border), brand ring + ~10dp solid
|
|
14
|
+
// inner dot when selected; press = android_ripple over a 40dp state layer;
|
|
15
|
+
// disabled opacity 0.38.
|
|
16
|
+
// Web: the established Canvas look (the current radio, lifted verbatim) —
|
|
17
|
+
// 14/16/20px ring per size, 2px border, brand ring + 6/8/10px primary dot.
|
|
18
|
+
|
|
19
|
+
// Ring diameter per size, per platform family. Default is the form control; small
|
|
20
|
+
// pairs with dense rows, large with touch-first layouts.
|
|
21
|
+
const WEB_RING: Record<Size, number> = { small: 14, default: 16, large: 20 };
|
|
22
|
+
const IOS_RING: Record<Size, number> = { small: 20, default: 22, large: 24 };
|
|
23
|
+
const ANDROID_RING: Record<Size, number> = { small: 18, default: 20, large: 22 };
|
|
24
|
+
|
|
25
|
+
// Inner dot diameter per size, per platform family. Web keeps ~half the ring; iOS
|
|
26
|
+
// runs ~40% of the ring; Android uses the M3 ~10dp dot at the default size.
|
|
27
|
+
const WEB_DOT: Record<Size, number> = { small: 6, default: 8, large: 10 };
|
|
28
|
+
const IOS_DOT: Record<Size, number> = { small: 8, default: 9, large: 10 };
|
|
29
|
+
const ANDROID_DOT: Record<Size, number> = { small: 9, default: 10, large: 11 };
|
|
30
|
+
|
|
31
|
+
// Label type per size (shared across platforms; the label is brand type, not a
|
|
32
|
+
// platform face). Matches the original Canvas label scale.
|
|
33
|
+
const LABEL_TYPE: Record<Size, TextStyle> = {
|
|
34
|
+
small: { fontSize: 12, lineHeight: 16 }, // text-xs
|
|
35
|
+
default: { fontSize: 14, lineHeight: 20 }, // text-sm
|
|
36
|
+
large: { fontSize: 16, lineHeight: 24 }, // text-base
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The label beside the control. Medium weight; dimmed to muted when disabled. Shared
|
|
40
|
+
// across every platform.
|
|
41
|
+
function label(tokens: ColorTokens, size: Size, disabled: boolean): TextStyle {
|
|
42
|
+
return {
|
|
43
|
+
fontWeight: "500",
|
|
44
|
+
color: disabled ? tokens["muted-foreground"] : tokens.foreground,
|
|
45
|
+
...LABEL_TYPE[size],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Ring base: a perfect circle, centered, nudged down (`marginTop: 3`) to align with
|
|
50
|
+
// the label's first line. Per-platform border weight is layered on by each skin.
|
|
51
|
+
function ringBase(box: number, nudge: boolean): ViewStyle {
|
|
52
|
+
return {
|
|
53
|
+
flexShrink: 0,
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
justifyContent: "center",
|
|
56
|
+
borderRadius: 9999,
|
|
57
|
+
width: box,
|
|
58
|
+
height: box,
|
|
59
|
+
backgroundColor: "transparent",
|
|
60
|
+
...(nudge ? { marginTop: 3 } : null),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Dot base: a centered circle. Color is the brand primary on every platform.
|
|
65
|
+
function dotBase(box: number): ViewStyle {
|
|
66
|
+
return { borderRadius: 9999, width: box, height: box };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- Web: the established Canvas look ----------
|
|
70
|
+
export const webSkin: RadioSkin = {
|
|
71
|
+
ring: (t, size, checked, nudge) => ({
|
|
72
|
+
...ringBase(WEB_RING[size], nudge),
|
|
73
|
+
borderWidth: 2,
|
|
74
|
+
borderColor: checked ? t.primary : t.input,
|
|
75
|
+
}),
|
|
76
|
+
dot: (t, size) => ({ ...dotBase(WEB_DOT[size]), backgroundColor: t.primary }),
|
|
77
|
+
label,
|
|
78
|
+
disabledOpacity: 0.5,
|
|
79
|
+
pressedOpacity: 0.9,
|
|
80
|
+
ripple: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---------- iOS (HIG): ~20pt ring, 1.5px border, dim on press ----------
|
|
84
|
+
export const iosSkin: RadioSkin = {
|
|
85
|
+
ring: (t, size, checked, nudge) => ({
|
|
86
|
+
...ringBase(IOS_RING[size], nudge),
|
|
87
|
+
borderWidth: 1.5, // hairline-plus when empty; the ring stays visible when selected
|
|
88
|
+
borderColor: checked ? t.primary : t.input,
|
|
89
|
+
}),
|
|
90
|
+
dot: (t, size) => ({ ...dotBase(IOS_DOT[size]), backgroundColor: t.primary }),
|
|
91
|
+
label,
|
|
92
|
+
disabledOpacity: 0.5,
|
|
93
|
+
pressedOpacity: 0.8,
|
|
94
|
+
ripple: null,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---------- Android (Material 3): 20dp ring, 2dp border, ~10dp dot, ripple ----------
|
|
98
|
+
export const androidSkin: RadioSkin = {
|
|
99
|
+
ring: (t, size, checked, nudge) => ({
|
|
100
|
+
...ringBase(ANDROID_RING[size], nudge),
|
|
101
|
+
borderWidth: 2,
|
|
102
|
+
borderColor: checked ? t.primary : t["muted-foreground"],
|
|
103
|
+
}),
|
|
104
|
+
dot: (t, size) => ({ ...dotBase(ANDROID_DOT[size]), backgroundColor: t.primary }),
|
|
105
|
+
label,
|
|
106
|
+
disabledOpacity: 0.38, // M3 disabled opacity
|
|
107
|
+
pressedOpacity: null, // Android uses a ripple instead
|
|
108
|
+
ripple: (t) => ({ color: t.primary, borderless: true, radius: 20 }), // 40dp state layer
|
|
109
|
+
};
|