@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,103 @@
|
|
|
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 Checkbox shell. Uses React Native's primitives DIRECTLY and reads the
|
|
6
|
+
// active brand tokens via useTheme, so colors follow light/dark and the glass
|
|
7
|
+
// surface. The shared structure (the box + glyph + label row, the size precedence,
|
|
8
|
+
// accessibility, onChange/onValueChange, indeterminate) lives here once; a platform
|
|
9
|
+
// file supplies only its skin (box shape/sizing/border, glyph color, press feedback)
|
|
10
|
+
// and calls createCheckbox. iOS has no native checkbox, so every skin is hand-drawn
|
|
11
|
+
// from the brand tokens — no platform default color ever leaks in.
|
|
12
|
+
|
|
13
|
+
export interface CheckboxProps {
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
/** Controlled checked state. The component renders exactly this value. */
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Mixed state: some-but-not-all selected. Shown as a dash, not a tick.
|
|
19
|
+
* Takes visual precedence over `checked`.
|
|
20
|
+
*/
|
|
21
|
+
indeterminate?: boolean;
|
|
22
|
+
/** Fired with the next checked value when the row is pressed. */
|
|
23
|
+
onChange?: (next: boolean) => void;
|
|
24
|
+
/** Alias of onChange, for parity with RN's value-style callbacks. */
|
|
25
|
+
onValueChange?: (next: boolean) => void;
|
|
26
|
+
// Size (pick one; default is the base box).
|
|
27
|
+
small?: boolean;
|
|
28
|
+
large?: boolean;
|
|
29
|
+
// State.
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Escape hatch for layout/positioning composition. */
|
|
32
|
+
style?: StyleProp<ViewStyle>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Size = "small" | "base" | "large";
|
|
36
|
+
|
|
37
|
+
// Size precedence when more than one is passed: first match wins.
|
|
38
|
+
function sizeOf(p: CheckboxProps): Size {
|
|
39
|
+
if (p.large) return "large";
|
|
40
|
+
if (p.small) return "small";
|
|
41
|
+
return "base";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The only thing a platform skin owns: the box, glyph, and label styles for a given
|
|
45
|
+
// state and size, plus the press/disabled feedback. Everything else is the shell.
|
|
46
|
+
export interface CheckboxSkin {
|
|
47
|
+
/** The square box. `filled` = checked or indeterminate. */
|
|
48
|
+
box: (tokens: ColorTokens, filled: boolean, size: Size) => ViewStyle;
|
|
49
|
+
/** The check / dash glyph inside a filled box. */
|
|
50
|
+
glyph: (tokens: ColorTokens, size: Size) => TextStyle;
|
|
51
|
+
/** The label text to the right of the box. */
|
|
52
|
+
label: (tokens: ColorTokens, size: Size) => TextStyle;
|
|
53
|
+
/** Opacity applied to the row when disabled. */
|
|
54
|
+
disabledOpacity: number;
|
|
55
|
+
/** iOS/web dim the row on press; Android uses a ripple instead (null). */
|
|
56
|
+
pressedOpacity: number | null;
|
|
57
|
+
/** Android ripple over the box; null on iOS/web. */
|
|
58
|
+
ripple: ((tokens: ColorTokens) => { color: string; borderless: boolean; radius?: number }) | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The row: box + optional label, top-aligned so a multi-line label hangs from the box.
|
|
62
|
+
const ROW: ViewStyle = { flexDirection: "row", alignItems: "flex-start", gap: 8 };
|
|
63
|
+
|
|
64
|
+
/** Build a Checkbox component from a platform skin. */
|
|
65
|
+
export function createCheckbox(skin: CheckboxSkin) {
|
|
66
|
+
return function Checkbox(props: CheckboxProps) {
|
|
67
|
+
const { children, checked, indeterminate, onChange, onValueChange, disabled, style } = props;
|
|
68
|
+
const size = sizeOf(props);
|
|
69
|
+
const { tokens } = useTheme();
|
|
70
|
+
// Indeterminate reads as "selected-ish": fill the box like a checked state.
|
|
71
|
+
const filled = indeterminate || !!checked;
|
|
72
|
+
const glyph = indeterminate ? "–" : "✓"; // en dash : check mark
|
|
73
|
+
|
|
74
|
+
const handlePress = (_event: GestureResponderEvent) => {
|
|
75
|
+
const next = !checked;
|
|
76
|
+
onChange?.(next);
|
|
77
|
+
onValueChange?.(next);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const ripple = skin.ripple ? skin.ripple(tokens) : undefined;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Pressable
|
|
84
|
+
onPress={handlePress}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
accessibilityRole="checkbox"
|
|
87
|
+
accessibilityState={{ checked: indeterminate ? "mixed" : !!checked, disabled: !!disabled }}
|
|
88
|
+
android_ripple={ripple}
|
|
89
|
+
style={({ pressed }) => [
|
|
90
|
+
ROW,
|
|
91
|
+
disabled ? { opacity: skin.disabledOpacity } : null,
|
|
92
|
+
skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null,
|
|
93
|
+
style,
|
|
94
|
+
]}
|
|
95
|
+
>
|
|
96
|
+
<View style={skin.box(tokens, filled, size)}>
|
|
97
|
+
{filled ? <Text style={skin.glyph(tokens, size)}>{glyph}</Text> : null}
|
|
98
|
+
</View>
|
|
99
|
+
{children != null ? <Text style={skin.label(tokens, size)}>{children}</Text> : null}
|
|
100
|
+
</Pressable>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type ViewStyle, type TextStyle } from "react-native";
|
|
2
|
+
import { type ColorTokens } from "../../style/index.js";
|
|
3
|
+
import { type CheckboxSkin, type Size } from "./checkbox.shared.js";
|
|
4
|
+
|
|
5
|
+
// Co-located Checkbox skins, one per platform, all driven by the brand tokens
|
|
6
|
+
// (passed in from useTheme so they follow light/dark and the glass surface). The
|
|
7
|
+
// BRAND survives on every platform (the filled box is always the indigo `primary`,
|
|
8
|
+
// never a platform default), and only the native SHAPE, sizing, border weight, and
|
|
9
|
+
// press feedback change per OS:
|
|
10
|
+
// iOS (HIG): no native checkbox; the de-facto rounded square (~5 radius), a
|
|
11
|
+
// hairline 1px border when empty, brand fill + white check when checked, the
|
|
12
|
+
// control nudged to ~20pt; press = opacity dim (~0.8).
|
|
13
|
+
// Android (Material 3): an 18dp square with a 2dp corner radius and a 2dp outline
|
|
14
|
+
// when empty, brand fill + white check when checked; press = android_ripple over
|
|
15
|
+
// a 40dp state layer; disabled opacity 0.38.
|
|
16
|
+
// Web: the established Canvas look (the current checkbox, lifted verbatim) —
|
|
17
|
+
// 14/16/20px box per size, 3 radius, 1px border, brand fill + foreground check.
|
|
18
|
+
|
|
19
|
+
// Box dimensions per size.
|
|
20
|
+
const WEB_BOX: Record<Size, number> = { small: 14, base: 16, large: 20 };
|
|
21
|
+
const IOS_BOX: Record<Size, number> = { small: 18, base: 20, large: 24 };
|
|
22
|
+
const ANDROID_BOX: Record<Size, number> = { small: 18, base: 18, large: 20 };
|
|
23
|
+
|
|
24
|
+
// Glyph (check / dash) type per box family. The check sits inside the box, so the
|
|
25
|
+
// font tracks the box size; `lineHeight: fontSize` keeps it optically centered.
|
|
26
|
+
function glyphType(fontSize: number): TextStyle {
|
|
27
|
+
return { fontSize, lineHeight: fontSize };
|
|
28
|
+
}
|
|
29
|
+
const WEB_GLYPH: Record<Size, number> = { small: 12, base: 12, large: 14 };
|
|
30
|
+
const IOS_GLYPH: Record<Size, number> = { small: 13, base: 14, large: 17 };
|
|
31
|
+
const ANDROID_GLYPH: Record<Size, number> = { small: 13, base: 13, large: 15 };
|
|
32
|
+
|
|
33
|
+
// Label type per size (shared across platforms; the label is brand type, not a
|
|
34
|
+
// platform face). Matches the original Canvas label scale.
|
|
35
|
+
const LABEL_TYPE: Record<Size, TextStyle> = {
|
|
36
|
+
small: { fontSize: 12, lineHeight: 16 }, // text-xs
|
|
37
|
+
base: { fontSize: 14, lineHeight: 20 }, // text-sm
|
|
38
|
+
large: { fontSize: 16, lineHeight: 24 }, // text-base
|
|
39
|
+
};
|
|
40
|
+
function label(tokens: ColorTokens, size: Size): TextStyle {
|
|
41
|
+
return { fontWeight: "500", color: tokens.foreground, ...LABEL_TYPE[size] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Box base: a square, centered, nudged down (`marginTop: 2`) to align with the
|
|
45
|
+
// label's first line. Per-platform radius/border weight is layered on by each skin.
|
|
46
|
+
function boxBase(box: number): ViewStyle {
|
|
47
|
+
return {
|
|
48
|
+
marginTop: 2,
|
|
49
|
+
flexShrink: 0,
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
width: box,
|
|
53
|
+
height: box,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------- Web: the established Canvas look ----------
|
|
58
|
+
export const webSkin: CheckboxSkin = {
|
|
59
|
+
box: (t, filled, size) => ({
|
|
60
|
+
...boxBase(WEB_BOX[size]),
|
|
61
|
+
borderRadius: 3,
|
|
62
|
+
borderWidth: 1,
|
|
63
|
+
...(filled
|
|
64
|
+
? { borderColor: t.primary, backgroundColor: t.primary }
|
|
65
|
+
: { borderColor: t.input, backgroundColor: "transparent" }),
|
|
66
|
+
}),
|
|
67
|
+
glyph: (t, size) => ({ fontWeight: "500", color: t["primary-foreground"], ...glyphType(WEB_GLYPH[size]) }),
|
|
68
|
+
label,
|
|
69
|
+
disabledOpacity: 0.5,
|
|
70
|
+
pressedOpacity: 0.9,
|
|
71
|
+
ripple: null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------- iOS (HIG): rounded square, hairline border, dim on press ----------
|
|
75
|
+
export const iosSkin: CheckboxSkin = {
|
|
76
|
+
box: (t, filled, size) => ({
|
|
77
|
+
...boxBase(IOS_BOX[size]),
|
|
78
|
+
borderRadius: size === "small" ? 4 : size === "large" ? 6 : 5,
|
|
79
|
+
borderWidth: 1, // hairline when empty; the fill hides it when checked
|
|
80
|
+
...(filled
|
|
81
|
+
? { borderColor: t.primary, backgroundColor: t.primary }
|
|
82
|
+
: { borderColor: t.input, backgroundColor: "transparent" }),
|
|
83
|
+
}),
|
|
84
|
+
glyph: (_t, size) => ({ fontWeight: "600", color: "#ffffff", ...glyphType(IOS_GLYPH[size]) }),
|
|
85
|
+
label,
|
|
86
|
+
disabledOpacity: 0.5,
|
|
87
|
+
pressedOpacity: 0.8,
|
|
88
|
+
ripple: null,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ---------- Android (Material 3): 18dp square, 2dp radius/outline, ripple ----------
|
|
92
|
+
export const androidSkin: CheckboxSkin = {
|
|
93
|
+
box: (t, filled, size) => ({
|
|
94
|
+
...boxBase(ANDROID_BOX[size]),
|
|
95
|
+
borderRadius: 2,
|
|
96
|
+
borderWidth: 2,
|
|
97
|
+
...(filled
|
|
98
|
+
? { borderColor: t.primary, backgroundColor: t.primary }
|
|
99
|
+
: { borderColor: t["muted-foreground"], backgroundColor: "transparent" }),
|
|
100
|
+
}),
|
|
101
|
+
glyph: (_t, size) => ({ fontWeight: "700", color: "#ffffff", ...glyphType(ANDROID_GLYPH[size]) }),
|
|
102
|
+
label,
|
|
103
|
+
disabledOpacity: 0.38, // M3 disabled opacity
|
|
104
|
+
pressedOpacity: null, // Android uses a ripple instead
|
|
105
|
+
ripple: (t) => ({ color: t.primary, borderless: true, radius: 20 }), // 40dp state layer
|
|
106
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createCheckbox } from "./checkbox.shared.js";
|
|
2
|
+
import { webSkin } from "./checkbox.styles.js";
|
|
3
|
+
|
|
4
|
+
// Web Checkbox (the base; Metro falls back to it on native, web bundlers resolve it).
|
|
5
|
+
export const Checkbox = createCheckbox(webSkin);
|
|
6
|
+
export type { CheckboxProps } from "./checkbox.shared.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createCombobox } from "./combobox.shared.js";
|
|
2
|
+
import { androidSkin } from "./combobox.styles.js";
|
|
3
|
+
|
|
4
|
+
// Material 3 Combobox. Metro resolves this file on Android; the docs import it for preview.
|
|
5
|
+
export const Combobox = createCombobox(androidSkin);
|
|
6
|
+
export type { ComboboxProps } from "./combobox.shared.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createCombobox } from "./combobox.shared.js";
|
|
2
|
+
import { iosSkin } from "./combobox.styles.js";
|
|
3
|
+
|
|
4
|
+
// iOS (HIG) Combobox. Metro resolves this file on iOS; the docs import it for preview.
|
|
5
|
+
export const Combobox = createCombobox(iosSkin);
|
|
6
|
+
export type { ComboboxProps } from "./combobox.shared.js";
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Comboboxes
|
|
2
|
+
|
|
3
|
+
Text input + dropdown: searchable single-select.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<Combobox
|
|
9
|
+
options={[
|
|
10
|
+
"Ada Lovelace",
|
|
11
|
+
"Grace Hopper",
|
|
12
|
+
"Kira Tanaka",
|
|
13
|
+
"Liang Bao",
|
|
14
|
+
"Marcus Allen",
|
|
15
|
+
"Noor Park",
|
|
16
|
+
"Rachel Chen"
|
|
17
|
+
]}
|
|
18
|
+
label="Assigned to"
|
|
19
|
+
placeholder="Search a person…"
|
|
20
|
+
style={{ maxWidth: 300 }}
|
|
21
|
+
/>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Variants
|
|
25
|
+
|
|
26
|
+
### With helper text
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
<Combobox
|
|
30
|
+
options={[
|
|
31
|
+
"Ada Lovelace",
|
|
32
|
+
"Grace Hopper",
|
|
33
|
+
"Kira Tanaka",
|
|
34
|
+
"Liang Bao",
|
|
35
|
+
"Marcus Allen",
|
|
36
|
+
"Noor Park",
|
|
37
|
+
"Rachel Chen"
|
|
38
|
+
]}
|
|
39
|
+
label="Assigned to"
|
|
40
|
+
helperText="The person responsible for this account."
|
|
41
|
+
placeholder="Search a person…"
|
|
42
|
+
style={{ maxWidth: 300 }}
|
|
43
|
+
/>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Disabled
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<Combobox
|
|
50
|
+
options={[
|
|
51
|
+
"Ada Lovelace",
|
|
52
|
+
"Grace Hopper",
|
|
53
|
+
"Kira Tanaka",
|
|
54
|
+
"Liang Bao",
|
|
55
|
+
"Marcus Allen",
|
|
56
|
+
"Noor Park",
|
|
57
|
+
"Rachel Chen"
|
|
58
|
+
]}
|
|
59
|
+
label="Assigned to"
|
|
60
|
+
placeholder="Search a person…"
|
|
61
|
+
disabled
|
|
62
|
+
style={{ maxWidth: 300 }}
|
|
63
|
+
/>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Do & Don't
|
|
67
|
+
|
|
68
|
+
### When to use
|
|
69
|
+
|
|
70
|
+
**Do** — A plain select for short, fixed lists; reserve the combobox for long, searchable ones.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
<Select label="Size" options={["Small", "Medium", "Large"]} open placeholder="Select a size" style={{ maxWidth: 280 }} />
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Don't** — Type or click: a search field for three fixed options is overhead with nothing to filter.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
<Combobox label="Size" options={["Small", "Medium", "Large"]} open placeholder="Search…" style={{ maxWidth: 280 }} />
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Filtering
|
|
83
|
+
|
|
84
|
+
**Do** — Type a few letters: the list narrows as you go, so a long list stays usable.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
<Combobox label="Assigned to" options={[
|
|
88
|
+
"Wade Cooper",
|
|
89
|
+
"Arlene Mccoy",
|
|
90
|
+
"Devon Webb",
|
|
91
|
+
"Tom Cook",
|
|
92
|
+
"Tanya Fox",
|
|
93
|
+
"Hellen Schmidt"
|
|
94
|
+
]} query="co" open style={{ maxWidth: 280 }} />
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Don't** — Try typing: a search box that ignores input is just a dropdown wearing a costume.
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
<View style={{ position: "relative", width: "100%", maxWidth: 280 }}>
|
|
101
|
+
<Text style={{ marginBottom: 6, fontWeight: "500", color: tokens.foreground, fontSize: 14, lineHeight: 20 }}>Assigned to</Text>
|
|
102
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderRadius: 6, borderWidth: 1, borderColor: tokens.input, backgroundColor: tokens.background, paddingHorizontal: 12, height: 36 }}>
|
|
103
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens.foreground }}>co</Text>
|
|
104
|
+
<Text style={{ color: tokens["muted-foreground"], fontSize: 14, lineHeight: 20 }}>▾</Text>
|
|
105
|
+
</View>
|
|
106
|
+
<View style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4, maxHeight: 240, borderRadius: 6, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.popover, padding: 4, ...shadow("lg") }}>
|
|
107
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
108
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
109
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Wade Cooper</Text>
|
|
110
|
+
</View>
|
|
111
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
112
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
113
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Arlene Mccoy</Text>
|
|
114
|
+
</View>
|
|
115
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
116
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
117
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Devon Webb</Text>
|
|
118
|
+
</View>
|
|
119
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
120
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
121
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tom Cook</Text>
|
|
122
|
+
</View>
|
|
123
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
124
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
125
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tanya Fox</Text>
|
|
126
|
+
</View>
|
|
127
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
128
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
129
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Hellen Schmidt</Text>
|
|
130
|
+
</View>
|
|
131
|
+
</View>
|
|
132
|
+
</View>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Selection
|
|
136
|
+
|
|
137
|
+
**Do** — Click an option: it fills the input and stays marked as selected.
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb", "Tom Cook"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Don't** — Click an option: it flashes but the field stays empty, so you can't tell what you picked.
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
<View style={{ position: "relative", width: "100%", maxWidth: 280 }}>
|
|
147
|
+
<Text style={{ marginBottom: 6, fontWeight: "500", color: tokens.foreground, fontSize: 14, lineHeight: 20 }}>Assigned to</Text>
|
|
148
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderRadius: 6, borderWidth: 1, borderColor: tokens.input, backgroundColor: tokens.background, paddingHorizontal: 12, height: 36 }}>
|
|
149
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["muted-foreground"] }}>Pick a person…</Text>
|
|
150
|
+
<Text style={{ color: tokens["muted-foreground"], fontSize: 14, lineHeight: 20 }}>▾</Text>
|
|
151
|
+
</View>
|
|
152
|
+
<View style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4, maxHeight: 240, borderRadius: 6, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.popover, padding: 4, ...shadow("lg") }}>
|
|
153
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
154
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
155
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Wade Cooper</Text>
|
|
156
|
+
</View>
|
|
157
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
158
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
159
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Arlene Mccoy</Text>
|
|
160
|
+
</View>
|
|
161
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
162
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
163
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Devon Webb</Text>
|
|
164
|
+
</View>
|
|
165
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 2, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
166
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"], width: 14 }}> </Text>
|
|
167
|
+
<Text style={{ fontSize: 14, lineHeight: 20, color: tokens["popover-foreground"] }}>Tom Cook</Text>
|
|
168
|
+
</View>
|
|
169
|
+
</View>
|
|
170
|
+
</View>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### With label
|
|
174
|
+
|
|
175
|
+
**Do** — A persistent label keeps the field named after a selection has filled the input.
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Don't** — Once a value replaces the placeholder, an unlabeled field has nothing left to name it.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
<Combobox options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" open style={{ maxWidth: 280 }} />
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### With helper text
|
|
188
|
+
|
|
189
|
+
**Do** — A short placeholder plus persistent helper text keeps the rule visible while you type.
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} open placeholder="Search a person…" helperText="Deactivated users are hidden from the list." style={{ maxWidth: 280 }} />
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Don't** — Type a letter: guidance crammed into the placeholder vanishes the moment you start.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} open placeholder="Pick an active teammate; deactivated users are hidden" style={{ maxWidth: 280 }} />
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Disabled
|
|
202
|
+
|
|
203
|
+
**Do** — Show the locked value and say why it's fixed, so disabled reads as a settled choice.
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} value="Devon Webb" disabled helperText="Set by the project owner and can't be changed here." style={{ maxWidth: 280 }} />
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Don't** — An empty, dimmed field with no value reads as broken, not as intentionally locked.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
<Combobox label="Assigned to" options={["Wade Cooper", "Arlene Mccoy", "Devon Webb"]} disabled placeholder="Search a person…" style={{ maxWidth: 280 }} />
|
|
213
|
+
```
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View, Pressable, Text, useTheme, type StyleProp, type ViewStyle } from "../../style/index.js";
|
|
3
|
+
import { wrapper, wrapperLifted } from "./combobox.styles.js";
|
|
4
|
+
import { type ComboboxSkin, type Size } from "./combobox.styles.js";
|
|
5
|
+
|
|
6
|
+
// Shared Combobox shell. A Combobox is a searchable single-select: it mirrors
|
|
7
|
+
// Select's structure (a field plus an open option list) and adds text filtering
|
|
8
|
+
// — the field shows the typed query, and the list narrows to options matching
|
|
9
|
+
// that query as you type.
|
|
10
|
+
//
|
|
11
|
+
// The structure (the field, the open/close state machine, the query filtering,
|
|
12
|
+
// the highlighted selected/active option, the helper text), the public
|
|
13
|
+
// boolean-prop API, the size precedence, accessibility, and handlers all live
|
|
14
|
+
// here once. A platform file supplies only its skin (field shape, fill,
|
|
15
|
+
// border/underline, popover elevation, row layout, press feedback) and calls
|
|
16
|
+
// createCombobox.
|
|
17
|
+
//
|
|
18
|
+
// Like Select, the open state is rendered inline (the docs render it this way;
|
|
19
|
+
// there is no portal/Modal). `open` defaults to true so the floating list is
|
|
20
|
+
// visible. The selected option carries a leading "✓" and an accent surface; an
|
|
21
|
+
// empty filtered list shows a muted "No results" row.
|
|
22
|
+
|
|
23
|
+
export interface ComboboxProps {
|
|
24
|
+
/** The text typed into the field. Filters the option list when set. */
|
|
25
|
+
query?: string;
|
|
26
|
+
/** The full list of selectable option labels. */
|
|
27
|
+
options?: string[];
|
|
28
|
+
/** The currently selected option label, marked with a check in the list. */
|
|
29
|
+
value?: string;
|
|
30
|
+
/** Prompt shown in the field when there is no query or value. */
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Whether the option list is open. Defaults to true so the open state is
|
|
34
|
+
* visible inline (the docs render it this way; there is no portal/Modal).
|
|
35
|
+
*/
|
|
36
|
+
open?: boolean;
|
|
37
|
+
/** Fired when the open state changes (field tap, select). */
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
/** Optional stacked field label rendered above the field. */
|
|
40
|
+
label?: string;
|
|
41
|
+
/** Optional muted helper line rendered below the option list. */
|
|
42
|
+
helperText?: string;
|
|
43
|
+
/** Dims the control and blocks interaction. */
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Called with the chosen option label when a row is pressed. */
|
|
46
|
+
onSelect?: (option: string) => void;
|
|
47
|
+
// Size (pick one; default is the medium field, matching Input's h-9).
|
|
48
|
+
small?: boolean;
|
|
49
|
+
large?: boolean;
|
|
50
|
+
/** Escape hatch for layout/positioning composition (mainly width). */
|
|
51
|
+
style?: StyleProp<ViewStyle>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// First match wins when more than one size flag is passed.
|
|
55
|
+
function sizeOf(p: ComboboxProps): Size {
|
|
56
|
+
if (p.small) return "small";
|
|
57
|
+
if (p.large) return "large";
|
|
58
|
+
return "default";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a Combobox component from a platform skin. */
|
|
62
|
+
export function createCombobox(skin: ComboboxSkin) {
|
|
63
|
+
return function Combobox(props: ComboboxProps) {
|
|
64
|
+
const {
|
|
65
|
+
query,
|
|
66
|
+
options = [],
|
|
67
|
+
value,
|
|
68
|
+
label,
|
|
69
|
+
helperText,
|
|
70
|
+
placeholder = "Search…",
|
|
71
|
+
open: openProp,
|
|
72
|
+
onOpenChange,
|
|
73
|
+
disabled,
|
|
74
|
+
onSelect,
|
|
75
|
+
style,
|
|
76
|
+
} = props;
|
|
77
|
+
const size = sizeOf(props);
|
|
78
|
+
const { tokens } = useTheme();
|
|
79
|
+
// Uncontrolled by default: the field opens/closes the list, a select closes it.
|
|
80
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
81
|
+
const open = openProp ?? internalOpen;
|
|
82
|
+
const setOpen = (next: boolean) => {
|
|
83
|
+
if (openProp === undefined) setInternalOpen(next);
|
|
84
|
+
onOpenChange?.(next);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// What the field shows: the typed query, then the selected value, else the
|
|
88
|
+
// placeholder. The first two read as foreground text; the placeholder is muted.
|
|
89
|
+
const hasQuery = query != null && query !== "";
|
|
90
|
+
const hasValue = value != null && value !== "";
|
|
91
|
+
const fieldText = hasQuery ? query : hasValue ? value : placeholder;
|
|
92
|
+
const fieldMuted = !hasQuery && !hasValue;
|
|
93
|
+
|
|
94
|
+
// Filter the list by the query (case-insensitive). With no query, show all.
|
|
95
|
+
const q = hasQuery ? (query as string).toLowerCase() : "";
|
|
96
|
+
const matches = hasQuery
|
|
97
|
+
? options.filter((o) => o.toLowerCase().includes(q))
|
|
98
|
+
: options;
|
|
99
|
+
|
|
100
|
+
const ripple = skin.ripple ? skin.ripple(tokens) : undefined;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<View style={[wrapper, open ? wrapperLifted : null, style]}>
|
|
104
|
+
{label != null && label !== "" ? (
|
|
105
|
+
<Text style={skin.label(tokens, size)}>{label}</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
<Pressable
|
|
108
|
+
style={({ pressed }) => [
|
|
109
|
+
skin.field(tokens, size, open),
|
|
110
|
+
skin.pressedOpacity != null && pressed ? { opacity: skin.pressedOpacity } : null,
|
|
111
|
+
disabled ? { opacity: skin.disabledOpacity } : null,
|
|
112
|
+
]}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
onPress={() => setOpen(!open)}
|
|
115
|
+
android_ripple={ripple}
|
|
116
|
+
accessibilityRole="button"
|
|
117
|
+
>
|
|
118
|
+
<Text style={skin.fieldText(tokens, size, fieldMuted)}>{fieldText}</Text>
|
|
119
|
+
<Text style={skin.chevron(tokens, size)}>▾</Text>
|
|
120
|
+
</Pressable>
|
|
121
|
+
|
|
122
|
+
{open ? (
|
|
123
|
+
<View style={skin.popover(tokens)}>
|
|
124
|
+
{matches.length === 0 ? (
|
|
125
|
+
<View style={skin.emptyRow}>
|
|
126
|
+
<Text style={skin.emptyText(tokens, size)}>No results</Text>
|
|
127
|
+
</View>
|
|
128
|
+
) : (
|
|
129
|
+
matches.map((option) => {
|
|
130
|
+
const selected = option === value;
|
|
131
|
+
return (
|
|
132
|
+
<Pressable
|
|
133
|
+
key={option}
|
|
134
|
+
style={({ pressed }) => [
|
|
135
|
+
skin.row,
|
|
136
|
+
selected || pressed ? skin.rowAccent(tokens) : null,
|
|
137
|
+
]}
|
|
138
|
+
onPress={() => {
|
|
139
|
+
onSelect?.(option);
|
|
140
|
+
setOpen(false);
|
|
141
|
+
}}
|
|
142
|
+
android_ripple={ripple}
|
|
143
|
+
accessibilityRole="button"
|
|
144
|
+
>
|
|
145
|
+
<Text style={skin.check(tokens, size)}>{selected ? "✓" : " "}</Text>
|
|
146
|
+
<Text style={skin.optionText(tokens, size)}>{option}</Text>
|
|
147
|
+
</Pressable>
|
|
148
|
+
);
|
|
149
|
+
})
|
|
150
|
+
)}
|
|
151
|
+
</View>
|
|
152
|
+
) : null}
|
|
153
|
+
|
|
154
|
+
{helperText != null && helperText !== "" ? (
|
|
155
|
+
<Text style={skin.helper(tokens)}>{helperText}</Text>
|
|
156
|
+
) : null}
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
}
|