@delacour/react-native-ui 0.1.0-alpha.20260925053522
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 +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +193 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +434 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +242 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +246 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +150 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +105 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +293 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +112 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +47 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +95 -0
- package/src/components/field/field.tsx +157 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +240 -0
- package/src/components/field/field.variants.ts +156 -0
- package/src/components/field/index.ts +22 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +74 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +261 -0
- package/src/components/pressable/pressable.variants.test.ts +128 -0
- package/src/components/pressable/pressable.variants.ts +80 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +245 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +580 -0
- package/src/components/radio/radio.variants.ts +271 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +50 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +193 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +856 -0
- package/src/components/slider/slider.variants.ts +661 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +287 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +670 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { ListGroupSize, ListGroupVariant } from "./list-group.variants";
|
|
3
|
+
|
|
4
|
+
export type ListGroupContextValue = {
|
|
5
|
+
/** Size of the list group. */
|
|
6
|
+
size: ListGroupSize;
|
|
7
|
+
/** Visual variant of the list group. */
|
|
8
|
+
variant: ListGroupVariant;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const ListGroupContext = createContext<ListGroupContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Supplies the enclosing list group's size and variant to its subtree.
|
|
15
|
+
*
|
|
16
|
+
* Lives in its own module, importing nothing but `list-group.variants`, so a
|
|
17
|
+
* component the list group renders can read it without importing `../list-group`
|
|
18
|
+
* and closing a cycle through `list-group.tsx`. See AGENTS.md rule 3.
|
|
19
|
+
*/
|
|
20
|
+
export function ListGroupProvider({
|
|
21
|
+
value,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
value: ListGroupContextValue;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}): ReactElement {
|
|
27
|
+
return <ListGroupContext value={value}>{children}</ListGroupContext>;
|
|
28
|
+
}
|
|
29
|
+
ListGroupProvider.displayName = "DelacourUI.ListGroup.Provider";
|
|
30
|
+
|
|
31
|
+
/** The enclosing list group's context, or null outside a `<ListGroup>`. */
|
|
32
|
+
export function useListGroupContext(): ListGroupContextValue | null {
|
|
33
|
+
return use(ListGroupContext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads the enclosing list group's size and variant.
|
|
38
|
+
*
|
|
39
|
+
* Lets a custom row style itself to match without the list group having to pass
|
|
40
|
+
* props down through every slot. Throws outside a `<ListGroup>` — use
|
|
41
|
+
* {@link useListGroupContext} where the enclosing list group is optional.
|
|
42
|
+
*/
|
|
43
|
+
export function useListGroup(): ListGroupContextValue {
|
|
44
|
+
const context = useListGroupContext();
|
|
45
|
+
if (!context) {
|
|
46
|
+
throw new Error("useListGroup must be called inside a <ListGroup>.");
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The enclosing list group's context, for a compound part that cannot work
|
|
53
|
+
* without one.
|
|
54
|
+
*
|
|
55
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
56
|
+
* library wants {@link useListGroup}, whose error message names the hook rather
|
|
57
|
+
* than a part.
|
|
58
|
+
*/
|
|
59
|
+
export function useListGroupPart(component: string): ListGroupContextValue {
|
|
60
|
+
const context = useListGroupContext();
|
|
61
|
+
if (!context) {
|
|
62
|
+
throw new Error(`${component} must be rendered inside a <ListGroup>.`);
|
|
63
|
+
}
|
|
64
|
+
return context;
|
|
65
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { Separator } from "../separator";
|
|
4
|
+
import { type ListGroupContextValue, ListGroupProvider } from "./list-group.context";
|
|
5
|
+
import { type ListGroupSize, type ListGroupVariant, listGroupVariants } from "./list-group.variants";
|
|
6
|
+
import { ListGroupItem } from "./list-group-item";
|
|
7
|
+
import { ListGroupItemContent } from "./list-group-item-content";
|
|
8
|
+
import { ListGroupItemDescription } from "./list-group-item-description";
|
|
9
|
+
import { ListGroupItemPrefix } from "./list-group-item-prefix";
|
|
10
|
+
import { ListGroupItemSuffix } from "./list-group-item-suffix";
|
|
11
|
+
import { ListGroupItemTitle } from "./list-group-item-title";
|
|
12
|
+
|
|
13
|
+
export type ListGroupProps = ViewProps & {
|
|
14
|
+
variant?: ListGroupVariant;
|
|
15
|
+
size?: ListGroupSize;
|
|
16
|
+
/** Draw a divider between adjacent children. On by default. */
|
|
17
|
+
isDivided?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function ListGroupRoot({
|
|
23
|
+
variant = "default",
|
|
24
|
+
size = "md",
|
|
25
|
+
isDivided = true,
|
|
26
|
+
className,
|
|
27
|
+
children,
|
|
28
|
+
...props
|
|
29
|
+
}: ListGroupProps): ReactElement {
|
|
30
|
+
const context = useMemo<ListGroupContextValue>(() => ({ size, variant }), [size, variant]);
|
|
31
|
+
|
|
32
|
+
const slots = listGroupVariants({ size, variant });
|
|
33
|
+
const dividerClassName = slots.divider();
|
|
34
|
+
|
|
35
|
+
const content = useMemo(
|
|
36
|
+
() => (isDivided ? withDividers(children, dividerClassName) : children),
|
|
37
|
+
[children, isDivided, dividerClassName]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ListGroupProvider value={context}>
|
|
42
|
+
<View className={slots.root({ className })} {...props}>
|
|
43
|
+
{content}
|
|
44
|
+
</View>
|
|
45
|
+
</ListGroupProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Inserts a divider between adjacent children.
|
|
51
|
+
*
|
|
52
|
+
* A pair is skipped when either side already is a `Separator`, so a hand-placed
|
|
53
|
+
* divider is never doubled. `Children.toArray` drops the nulls and booleans a
|
|
54
|
+
* conditional child leaves behind, so a row rendered only some of the time does
|
|
55
|
+
* not strand a divider where nothing follows it.
|
|
56
|
+
*/
|
|
57
|
+
function withDividers(children: ReactNode, dividerClassName: string): ReactNode {
|
|
58
|
+
const items = Children.toArray(children);
|
|
59
|
+
const output: ReactNode[] = [];
|
|
60
|
+
|
|
61
|
+
for (const [index, child] of items.entries()) {
|
|
62
|
+
if (index > 0 && !isSeparator(items[index - 1]) && !isSeparator(child)) {
|
|
63
|
+
output.push(<Separator className={dividerClassName} key={`divider-${index}`} />);
|
|
64
|
+
}
|
|
65
|
+
output.push(child);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return output;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isSeparator(node: ReactNode): boolean {
|
|
72
|
+
return isValidElement(node) && node.type === Separator;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A surface grouping related rows under one rounded, clipped container.
|
|
77
|
+
*
|
|
78
|
+
* `size` and `variant` reach the sub-components through context, so a row's
|
|
79
|
+
* title picks its own text colour and type scale. That indirection is not
|
|
80
|
+
* incidental: a React Native `View` does not cascade colour to a `Text`
|
|
81
|
+
* descendant the way a DOM element would.
|
|
82
|
+
*
|
|
83
|
+
* Dividers are inserted between adjacent children rather than written out at
|
|
84
|
+
* every call site, inset to line up with the rows' own padding. A `Separator`
|
|
85
|
+
* placed by hand is respected — no divider is added on either side of it — so a
|
|
86
|
+
* caller can still control one gap without turning the whole feature off.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* <ListGroup>
|
|
90
|
+
* <ListGroup.Item onPress={openProfile}>
|
|
91
|
+
* <ListGroup.ItemPrefix>
|
|
92
|
+
* <Icon icon={IconUser} />
|
|
93
|
+
* </ListGroup.ItemPrefix>
|
|
94
|
+
* <ListGroup.ItemContent>
|
|
95
|
+
* <ListGroup.ItemTitle>Personal info</ListGroup.ItemTitle>
|
|
96
|
+
* <ListGroup.ItemDescription>Name, email, phone</ListGroup.ItemDescription>
|
|
97
|
+
* </ListGroup.ItemContent>
|
|
98
|
+
* <ListGroup.ItemSuffix />
|
|
99
|
+
* </ListGroup.Item>
|
|
100
|
+
* </ListGroup>
|
|
101
|
+
*/
|
|
102
|
+
export const ListGroup = Object.assign(ListGroupRoot, {
|
|
103
|
+
/** One row of a list group. Bare text children are wrapped in a title inside a content column. */
|
|
104
|
+
Item: ListGroupItem,
|
|
105
|
+
/** The leading slot of a row. Its subtree inherits the group's icon size and foreground token. */
|
|
106
|
+
ItemPrefix: ListGroupItemPrefix,
|
|
107
|
+
/** The text column of a row, taking whatever width the prefix and suffix leave. */
|
|
108
|
+
ItemContent: ListGroupItemContent,
|
|
109
|
+
/** The row's primary line. Carries its own colour — a `View` cannot cascade one to a `Text`. */
|
|
110
|
+
ItemTitle: ListGroupItemTitle,
|
|
111
|
+
/** The row's secondary line, a step down in scale and on the muted token. */
|
|
112
|
+
ItemDescription: ListGroupItemDescription,
|
|
113
|
+
/** The trailing slot of a row, showing a chevron unless given content. */
|
|
114
|
+
ItemSuffix: ListGroupItemSuffix,
|
|
115
|
+
displayName: "DelacourUI.ListGroup",
|
|
116
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { TextProps, ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shape of a row's leading, text and trailing slots.
|
|
6
|
+
*
|
|
7
|
+
* Shared by `ListGroup.ItemPrefix` and `ListGroup.ItemContent`, and extended by
|
|
8
|
+
* `ListGroup.ItemSuffix`, so it lives in a leaf rather than in one of them
|
|
9
|
+
* arbitrarily.
|
|
10
|
+
*/
|
|
11
|
+
export type ListGroupSlotProps = ViewProps & { className?: string; children?: ReactNode };
|
|
12
|
+
|
|
13
|
+
/** The shape of a row's two text lines, `ItemTitle` and `ItemDescription`. */
|
|
14
|
+
export type ListGroupTextProps = TextProps & { className?: string };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
|
+
import { LIST_GROUP_SIZES, LIST_GROUP_VARIANTS, listGroupVariants } from "./list-group.variants";
|
|
4
|
+
|
|
5
|
+
/** Pulls the horizontal padding step out of a class string — `px-4` yields 4. */
|
|
6
|
+
function paddingStep(cls: string): string | undefined {
|
|
7
|
+
return cls.match(/\bpx-(\d+(?:\.\d+)?)\b/)?.[1];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pulls the horizontal margin step out of a class string — `mx-4` yields 4. */
|
|
11
|
+
function marginStep(cls: string): string | undefined {
|
|
12
|
+
return cls.match(/\bmx-(\d+(?:\.\d+)?)\b/)?.[1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Position of a class string's `size-icon-*` token on the shared icon scale.
|
|
17
|
+
*
|
|
18
|
+
* Compares by step rather than by points, so a test says what it means and
|
|
19
|
+
* survives a token being retuned in `tokens.css`. `tokens.test.ts` is what
|
|
20
|
+
* keeps that array ordered.
|
|
21
|
+
*/
|
|
22
|
+
function iconStep(cls: string): number {
|
|
23
|
+
const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
|
|
24
|
+
return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("listGroupVariants root slot", () => {
|
|
28
|
+
test("defaults to the card surface at md", () => {
|
|
29
|
+
const cls = listGroupVariants().root();
|
|
30
|
+
expect(cls).toContain("bg-card");
|
|
31
|
+
expect(cls).toContain("border-border");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("gives every variant a distinct surface", () => {
|
|
35
|
+
const seen = new Set(LIST_GROUP_VARIANTS.map((variant) => listGroupVariants({ variant }).root()));
|
|
36
|
+
expect(seen.size).toBe(LIST_GROUP_VARIANTS.length);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("maps each variant to its surface", () => {
|
|
40
|
+
expect(listGroupVariants({ variant: "default" }).root()).toContain("bg-card");
|
|
41
|
+
expect(listGroupVariants({ variant: "secondary" }).root()).toContain("bg-secondary");
|
|
42
|
+
expect(listGroupVariants({ variant: "tertiary" }).root()).toContain("bg-tertiary");
|
|
43
|
+
expect(listGroupVariants({ variant: "transparent" }).root()).toContain("bg-transparent");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("only the default variant draws a border", () => {
|
|
47
|
+
expect(listGroupVariants({ variant: "default" }).root()).toContain("border-border");
|
|
48
|
+
for (const variant of LIST_GROUP_VARIANTS.filter((name) => name !== "default")) {
|
|
49
|
+
expect(listGroupVariants({ variant }).root()).not.toContain("border-border");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The root's radius has to clip its rows: a pressed row fades to the edge of
|
|
54
|
+
// its own box, and without this the fade squares off the group's corners.
|
|
55
|
+
test("clips its rows at every variant and size", () => {
|
|
56
|
+
for (const variant of LIST_GROUP_VARIANTS) {
|
|
57
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
58
|
+
const cls = listGroupVariants({ size, variant }).root();
|
|
59
|
+
expect(cls).toContain("overflow-hidden");
|
|
60
|
+
expect(cls).toMatch(/\brounded-/);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Rule 1: a React Native View does not cascade colour to a Text descendant,
|
|
66
|
+
// so the text tokens belong on the title and description, never here.
|
|
67
|
+
test("carries no text treatment", () => {
|
|
68
|
+
for (const variant of LIST_GROUP_VARIANTS) {
|
|
69
|
+
expect(listGroupVariants({ variant }).root()).not.toMatch(/\btext-/);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("merges an incoming className last", () => {
|
|
74
|
+
expect(listGroupVariants().root({ className: "mb-6" })).toContain("mb-6");
|
|
75
|
+
expect(listGroupVariants().root({ className: "bg-popover" })).not.toContain("bg-card");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("listGroupVariants item slot", () => {
|
|
80
|
+
test("lays a row out horizontally across its full width", () => {
|
|
81
|
+
const cls = listGroupVariants().item();
|
|
82
|
+
expect(cls).toContain("flex-row");
|
|
83
|
+
expect(cls).toContain("items-center");
|
|
84
|
+
expect(cls).toContain("w-full");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("gives every size a distinct row metric", () => {
|
|
88
|
+
const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).item()));
|
|
89
|
+
expect(seen.size).toBe(LIST_GROUP_SIZES.length);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("pads and spaces every size", () => {
|
|
93
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
94
|
+
const cls = listGroupVariants({ size }).item();
|
|
95
|
+
expect(cls).toMatch(/\bmin-h-\d/);
|
|
96
|
+
expect(cls).toMatch(/\bpx-\d/);
|
|
97
|
+
expect(cls).toMatch(/\bpy-\d/);
|
|
98
|
+
expect(cls).toMatch(/\bgap-\d/);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("adds the disabled treatment only when disabled", () => {
|
|
103
|
+
expect(listGroupVariants({ isDisabled: true }).item()).toContain("opacity-50");
|
|
104
|
+
expect(listGroupVariants({ isDisabled: false }).item()).not.toContain("opacity-50");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Rule 1 again — the row is a View, so the title owns its own colour.
|
|
108
|
+
test("carries no text treatment", () => {
|
|
109
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
110
|
+
expect(listGroupVariants({ size }).item()).not.toMatch(/\btext-/);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("merges an incoming className last", () => {
|
|
115
|
+
expect(listGroupVariants().item({ className: "opacity-80" })).toContain("opacity-80");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("listGroupVariants divider slot", () => {
|
|
120
|
+
// The load-bearing coupling: an auto-inserted divider is inset to line up with
|
|
121
|
+
// the row's own padding. Drift between the two is what makes a grouped list
|
|
122
|
+
// look wrong, so the pair is asserted together rather than pinned separately.
|
|
123
|
+
// One slotted tv is what now makes this hard to break — both read one axis.
|
|
124
|
+
test("insets the divider to the row's horizontal padding at every size", () => {
|
|
125
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
126
|
+
const slots = listGroupVariants({ size });
|
|
127
|
+
const inset = marginStep(slots.divider());
|
|
128
|
+
const padding = paddingStep(slots.item());
|
|
129
|
+
expect(inset).toBeDefined();
|
|
130
|
+
expect(inset).toBe(padding);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("gives every size a distinct inset", () => {
|
|
135
|
+
const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).divider()));
|
|
136
|
+
expect(seen.size).toBe(LIST_GROUP_SIZES.length);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The line itself comes from Separator; this only positions it.
|
|
140
|
+
test("carries no colour of its own", () => {
|
|
141
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
142
|
+
expect(listGroupVariants({ size }).divider()).not.toMatch(/\b(bg|text|border)-/);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("listGroupVariants content slot", () => {
|
|
148
|
+
test("takes the space the prefix and suffix leave", () => {
|
|
149
|
+
expect(listGroupVariants().content()).toContain("flex-1");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("listGroupVariants title slot", () => {
|
|
154
|
+
test("carries the foreground colour, which the row must not", () => {
|
|
155
|
+
expect(listGroupVariants().title()).toContain("text-foreground");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("gives every size a distinct type scale", () => {
|
|
159
|
+
const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).title()));
|
|
160
|
+
expect(seen.size).toBe(LIST_GROUP_SIZES.length);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("merges an incoming className last", () => {
|
|
164
|
+
expect(listGroupVariants().title({ className: "font-bold" })).toContain("font-bold");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("listGroupVariants description slot", () => {
|
|
169
|
+
test("sits back from the title on the muted token", () => {
|
|
170
|
+
expect(listGroupVariants().description()).toContain("text-muted-foreground");
|
|
171
|
+
expect(listGroupVariants().description()).not.toContain("text-foreground");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("gives every size a distinct type scale", () => {
|
|
175
|
+
const seen = new Set(LIST_GROUP_SIZES.map((size) => listGroupVariants({ size }).description()));
|
|
176
|
+
expect(seen.size).toBe(LIST_GROUP_SIZES.length);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("stays smaller than the title it sits under", () => {
|
|
180
|
+
const scale = ["text-xs", "text-sm", "text-base", "text-lg"];
|
|
181
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
182
|
+
const slots = listGroupVariants({ size });
|
|
183
|
+
const title = scale.findIndex((step) => slots.title().includes(step));
|
|
184
|
+
const description = scale.findIndex((step) => slots.description().includes(step));
|
|
185
|
+
expect(description).toBeGreaterThanOrEqual(0);
|
|
186
|
+
expect(description).toBeLessThan(title);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("listGroupVariants icon slots", () => {
|
|
192
|
+
test("give every size a distinct prefix icon token, increasing with it", () => {
|
|
193
|
+
const steps = LIST_GROUP_SIZES.map((size) => iconStep(listGroupVariants({ size }).prefixIcon()));
|
|
194
|
+
expect(steps).not.toContain(-1);
|
|
195
|
+
expect(new Set(steps).size).toBe(LIST_GROUP_SIZES.length);
|
|
196
|
+
expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("give every size a distinct suffix icon token, increasing with it", () => {
|
|
200
|
+
const steps = LIST_GROUP_SIZES.map((size) => iconStep(listGroupVariants({ size }).suffixIcon()));
|
|
201
|
+
expect(steps).not.toContain(-1);
|
|
202
|
+
expect(new Set(steps).size).toBe(LIST_GROUP_SIZES.length);
|
|
203
|
+
expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// The trailing chevron is a hint, not content: it stays under the leading
|
|
207
|
+
// icon so a row reads left to right rather than being pulled to its edge.
|
|
208
|
+
test("keep the suffix icon smaller than the prefix icon at every size", () => {
|
|
209
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
210
|
+
const slots = listGroupVariants({ size });
|
|
211
|
+
expect(iconStep(slots.suffixIcon())).toBeLessThan(iconStep(slots.prefixIcon()));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// An icon takes a colour value rather than a class — the slot sizes it only.
|
|
216
|
+
test("carry no colour", () => {
|
|
217
|
+
for (const size of LIST_GROUP_SIZES) {
|
|
218
|
+
const slots = listGroupVariants({ size });
|
|
219
|
+
expect(slots.prefixIcon()).not.toMatch(/\b(bg|text|border)-/);
|
|
220
|
+
expect(slots.suffixIcon()).not.toMatch(/\b(bg|text|border)-/);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
|
|
4
|
+
export const LIST_GROUP_VARIANTS = ["default", "secondary", "tertiary", "transparent"] as const;
|
|
5
|
+
|
|
6
|
+
export const LIST_GROUP_SIZES = ["sm", "md", "lg"] as const;
|
|
7
|
+
|
|
8
|
+
export type ListGroupVariant = (typeof LIST_GROUP_VARIANTS)[number];
|
|
9
|
+
export type ListGroupSize = (typeof LIST_GROUP_SIZES)[number];
|
|
10
|
+
|
|
11
|
+
/** Theme token an icon in `ListGroup.ItemPrefix` inherits. */
|
|
12
|
+
export const LIST_GROUP_FOREGROUND_TOKEN = "foreground";
|
|
13
|
+
|
|
14
|
+
/** Theme token the default trailing chevron inherits. */
|
|
15
|
+
export const LIST_GROUP_SUFFIX_ICON_TOKEN = "muted-foreground";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Styling for every part of a list group.
|
|
19
|
+
*
|
|
20
|
+
* Both icon slots name a step on the shared `--spacing-icon-*` scale, the same
|
|
21
|
+
* one `Icon` and `Spinner` use, so a row's glyph lines up with every other glyph
|
|
22
|
+
* in the library rather than carrying a private number.
|
|
23
|
+
*
|
|
24
|
+
* One slotted `tv()` rather than a call per part, because size is not
|
|
25
|
+
* decoration here: it drives the row metrics, the title and description type
|
|
26
|
+
* scale, both icon sizes *and* the divider inset. Those five belong on one axis
|
|
27
|
+
* rather than in five places that can drift apart — the divider inset tracking
|
|
28
|
+
* the row's padding is asserted as a pair in the tests for exactly that reason.
|
|
29
|
+
*
|
|
30
|
+
* `overflow-hidden` on the root is load-bearing rather than tidiness: a pressed
|
|
31
|
+
* row fades to the edge of its own box, and the first and last rows would square
|
|
32
|
+
* off the group's corners without it.
|
|
33
|
+
*
|
|
34
|
+
* The root and item slots hold no `text-*` utility — a React Native `View` does
|
|
35
|
+
* not cascade colour to a `Text` descendant, so the text tokens live on the
|
|
36
|
+
* title and description slots.
|
|
37
|
+
*
|
|
38
|
+
* `w-full` on the item matters because the root clips: without it a short row
|
|
39
|
+
* leaves the press feedback ending mid-card instead of spanning it.
|
|
40
|
+
*
|
|
41
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
42
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
43
|
+
*/
|
|
44
|
+
export const listGroupVariants = tv({
|
|
45
|
+
slots: {
|
|
46
|
+
root: "overflow-hidden border border-transparent",
|
|
47
|
+
item: "w-full flex-row items-center",
|
|
48
|
+
/** Positioning only for a divider the root inserts — the line is a `Separator`. */
|
|
49
|
+
divider: "",
|
|
50
|
+
prefix: "items-center justify-center",
|
|
51
|
+
/** Edge length an `Icon` in the prefix inherits. */
|
|
52
|
+
prefixIcon: "",
|
|
53
|
+
content: "flex-1 justify-center gap-0.5",
|
|
54
|
+
title: "font-medium text-foreground",
|
|
55
|
+
description: "text-muted-foreground",
|
|
56
|
+
suffix: "items-center justify-center",
|
|
57
|
+
/** Edge length of the default trailing chevron. */
|
|
58
|
+
suffixIcon: "",
|
|
59
|
+
},
|
|
60
|
+
variants: {
|
|
61
|
+
variant: {
|
|
62
|
+
default: { root: "border-border bg-card" },
|
|
63
|
+
secondary: { root: "bg-secondary" },
|
|
64
|
+
tertiary: { root: "bg-tertiary" },
|
|
65
|
+
transparent: { root: "bg-transparent" },
|
|
66
|
+
},
|
|
67
|
+
size: {
|
|
68
|
+
sm: {
|
|
69
|
+
root: "rounded-md",
|
|
70
|
+
item: "min-h-12 gap-2.5 px-3 py-2",
|
|
71
|
+
divider: "mx-3",
|
|
72
|
+
prefixIcon: "size-icon-md",
|
|
73
|
+
suffixIcon: "size-icon-xs",
|
|
74
|
+
title: "text-sm",
|
|
75
|
+
description: "text-xs",
|
|
76
|
+
},
|
|
77
|
+
md: {
|
|
78
|
+
root: "rounded-md",
|
|
79
|
+
item: "min-h-14 gap-3 px-4 py-3",
|
|
80
|
+
divider: "mx-4",
|
|
81
|
+
prefixIcon: "size-icon-lg",
|
|
82
|
+
suffixIcon: "size-icon-sm",
|
|
83
|
+
title: "text-base",
|
|
84
|
+
description: "text-sm",
|
|
85
|
+
},
|
|
86
|
+
lg: {
|
|
87
|
+
root: "rounded-md",
|
|
88
|
+
item: "min-h-16 gap-3.5 px-5 py-4",
|
|
89
|
+
divider: "mx-5",
|
|
90
|
+
prefixIcon: "size-icon-xl",
|
|
91
|
+
suffixIcon: "size-icon-md",
|
|
92
|
+
title: "text-lg",
|
|
93
|
+
description: "text-base",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
// The empty `false` branch types the prop as `boolean` rather than `true`.
|
|
97
|
+
// See the note in button.variants.ts.
|
|
98
|
+
isDisabled: { true: { item: "opacity-50" }, false: {} },
|
|
99
|
+
},
|
|
100
|
+
defaultVariants: {
|
|
101
|
+
variant: "default",
|
|
102
|
+
size: "md",
|
|
103
|
+
isDisabled: false,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export type ListGroupVariantProps = VariantProps<typeof listGroupVariants>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Pressable
|
|
2
|
+
|
|
3
|
+
The interaction primitive every pressable component in this library builds on —
|
|
4
|
+
a Gesture Handler tap drives the press feedback and the haptic on the UI thread,
|
|
5
|
+
and only `onPress` and `onLongPress` cross back to JS.
|
|
6
|
+
|
|
7
|
+
`import { Pressable } from "@delacour/react-native-ui/pressable";`
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
| File | What it holds |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `index.ts` | → `@delacour/react-native-ui/pressable` |
|
|
14
|
+
| `pressable.tsx` | The Gesture API primitive |
|
|
15
|
+
| `pressable.variants.ts` | Shared feedback vocabulary, no RN imports |
|
|
16
|
+
| `pressable.variants.test.ts` | |
|
|
17
|
+
|
|
18
|
+
## Design
|
|
19
|
+
|
|
20
|
+
- **`pressable.variants.ts` holds no `tv()`, on purpose.** Its values are
|
|
21
|
+
opacity and scale *interpolation targets* read by a worklet on the UI thread
|
|
22
|
+
(`1 - pressed.value * (1 - targetOpacity)`), not styles. A worklet cannot
|
|
23
|
+
compile a className, so `tv` is the wrong tool here — a deliberate exception
|
|
24
|
+
to pattern C, not an oversight.
|
|
25
|
+
- **`feedback` is the shared vocabulary**: `scale`, `fade`, `scale-fade`,
|
|
26
|
+
`none`, defined once in `pressable.variants.ts`. Every pressable in the
|
|
27
|
+
library resolves through it rather than keeping a private map. A control
|
|
28
|
+
changes its **default** and nothing else — [`Button`](../button/AGENTS.md) to
|
|
29
|
+
`scale`, [`ListGroup.Item`](../list-group/AGENTS.md) to `fade`. Neither
|
|
30
|
+
narrows the union: a second definition of a prop the control does not change
|
|
31
|
+
is a definition that can drift, which is why the button's own
|
|
32
|
+
`ButtonFeedback` was removed.
|
|
33
|
+
- **`scale-fade` is composed, not spelled out.** Its scale comes from `scale`
|
|
34
|
+
and its opacity from `fade`, so tuning either single-axis mode carries
|
|
35
|
+
through and the name keeps describing what the mode does. A test asserts it.
|
|
36
|
+
- **`pressedScale` / `pressedOpacity` win on the axis they name**, and leave the
|
|
37
|
+
other to `feedback`. They are the escape hatch for a value the named modes do
|
|
38
|
+
not cover, not an alternative API — `??` is the merge, so an explicit `0` is a
|
|
39
|
+
value rather than an absence.
|
|
40
|
+
- **The no-feedback fallback is unnamed on purpose.** A bare `Pressable` presses
|
|
41
|
+
to `{ opacity: 0.9, scale: 0.97 }`, which fades less than `fade` does. Naming
|
|
42
|
+
it would either change what a bare pressable has always done or force
|
|
43
|
+
`scale-fade` to fade less than `fade`.
|
|
44
|
+
- **`disabled` vs `busy`.** Both block the gesture; only `disabled` announces
|
|
45
|
+
the control as disabled. Use `busy` for a temporary state the component clears
|
|
46
|
+
itself, so assistive tech reports a control that is momentarily unavailable
|
|
47
|
+
rather than one that is inert. Neither applies any opacity — that is the
|
|
48
|
+
caller's variant's job, and the point below is what lets it do that job.
|
|
49
|
+
- **A className's opacity is multiplied into the press, never overwritten.**
|
|
50
|
+
The press feedback is an animated style, and an animated style owns every key
|
|
51
|
+
it writes on the view — so its resting `opacity: 1` silently replaced a
|
|
52
|
+
variant's `opacity-50`, and a disabled `Button`, `Badge`, `Checkbox`,
|
|
53
|
+
`ListGroup.Item` or `Input.Group` never faded while blocking taps correctly.
|
|
54
|
+
The root now resolves its own `className` with Uniwind's
|
|
55
|
+
`useResolveClassNames` and animates `rest * press` instead, with
|
|
56
|
+
`resolveRestOpacity` guarding the value. A press still fades relative to the
|
|
57
|
+
rest — a disabled control cannot be pressed, so the two never compound. No
|
|
58
|
+
unit test could have seen the bug; it was found on a simulator.
|
|
59
|
+
- **A worklet crosses back to JS with `scheduleOnRN`**, imported from
|
|
60
|
+
`react-native-worklets` — never Reanimated's `runOnJS`, which since Reanimated 4
|
|
61
|
+
is a deprecated shim that forwards to exactly that call. `scheduleOnRN(fn, ...args)`
|
|
62
|
+
takes the arguments itself, so there is no second call: `runOnJS(onPress)()`
|
|
63
|
+
becomes `scheduleOnRN(onPress)`. The same rename covers `runOnUI` →
|
|
64
|
+
`scheduleOnUI`, which `use-keyboard-state-sync` already uses. A Biome
|
|
65
|
+
`noRestrictedImports` rule in `@delacour/biome-config` fails the build on the
|
|
66
|
+
deprecated names, so this cannot regress quietly.
|
|
67
|
+
- **A worklet's body must be self-contained.** The press feedback runs on the UI
|
|
68
|
+
thread and cannot rely on a module-scope helper: factoring its
|
|
69
|
+
`Math.min(1, Math.max(0, …))` clamp into a `clampUnit` helper — itself marked
|
|
70
|
+
`"worklet"` — crashes the UI thread with `undefined is not a function`, because
|
|
71
|
+
the helper is not captured into the worklet's closure. No unit test sees it,
|
|
72
|
+
since on the JS thread the helper resolves perfectly.
|
|
73
|
+
[`Screen`](../screen/AGENTS.md)'s border ramps write the same clamp out twice
|
|
74
|
+
for this reason.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { type HapticFeedback, Pressable, type PressableProps, playHaptic } from "./pressable";
|
|
2
|
+
export {
|
|
3
|
+
PRESSABLE_FEEDBACK,
|
|
4
|
+
PRESSABLE_FEEDBACK_FALLBACK,
|
|
5
|
+
PRESSABLE_FEEDBACKS,
|
|
6
|
+
type PressableFeedback,
|
|
7
|
+
type PressedState,
|
|
8
|
+
resolvePressedState,
|
|
9
|
+
} from "./pressable.variants";
|