@delacour/react-native-ui 0.1.0-alpha.3
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 +192 -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 +435 -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 +232 -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 +245 -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 +148 -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 +95 -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 +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -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 +100 -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 +33 -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 +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -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 +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -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 +247 -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 +581 -0
- package/src/components/radio/radio.variants.ts +272 -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 +48 -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 +161 -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 +804 -0
- package/src/components/slider/slider.variants.ts +607 -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 +286 -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 +672 -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,61 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useFieldContext } from "./field.context";
|
|
4
|
+
import type { FieldTextProps } from "./field.types";
|
|
5
|
+
import { fieldVariants, resolveFieldTextColor } from "./field.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `Text` renders `Animated.Text`, so its `children` may also be a Reanimated
|
|
9
|
+
* `SharedValue<ReactNode>`. An error message is a string, and this part has to
|
|
10
|
+
* count its children to know whether to render at all, so the narrower type is
|
|
11
|
+
* both the honest one and the one that can be counted.
|
|
12
|
+
*/
|
|
13
|
+
export type FieldErrorProps = Omit<FieldTextProps, "children"> & { children?: ReactNode };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What is wrong with the value.
|
|
17
|
+
*
|
|
18
|
+
* Renders nothing when it has no children, so
|
|
19
|
+
* `<Field.Error>{errors.email}</Field.Error>` disappears on its own once the
|
|
20
|
+
* value is fixed and needs no conditional at the call site.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately **not** gated on the field's `isInvalid`. A part that swallowed
|
|
23
|
+
* the children a caller actually wrote, because of a prop on a sibling, would be
|
|
24
|
+
* a part whose absence is unexplainable from the call site — and pairing the two
|
|
25
|
+
* is the caller's own `isInvalid={!!error}`, which is one expression, in view.
|
|
26
|
+
*
|
|
27
|
+
* Always destructive, in a valid field as much as an invalid one: an error message is
|
|
28
|
+
* never the calm case.
|
|
29
|
+
*/
|
|
30
|
+
export function FieldError({ className, color, children, ...props }: FieldErrorProps): ReactElement | null {
|
|
31
|
+
const field = useFieldContext();
|
|
32
|
+
const isInvalid = field?.isInvalid ?? false;
|
|
33
|
+
const isDisabled = field?.isDisabled ?? false;
|
|
34
|
+
|
|
35
|
+
if (!hasContent(children)) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Text.Caption
|
|
39
|
+
accessibilityLiveRegion="polite"
|
|
40
|
+
className={fieldVariants({ isDisabled, isInvalid }).error({ className })}
|
|
41
|
+
color={color ?? resolveFieldTextColor("error", isInvalid)}
|
|
42
|
+
role="alert"
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</Text.Caption>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
FieldError.displayName = "DelacourUI.Field.Error";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether there is anything worth drawing a line of destructive text for.
|
|
53
|
+
*
|
|
54
|
+
* `Children.toArray` drops the `null`, `undefined` and booleans a conditional
|
|
55
|
+
* child leaves behind, so `{error && <Text>{error}</Text>}` counts as empty
|
|
56
|
+
* rather than as one child. An empty string is dropped too — it is what a
|
|
57
|
+
* cleared validation message usually is.
|
|
58
|
+
*/
|
|
59
|
+
function hasContent(children: ReactNode): boolean {
|
|
60
|
+
return Children.toArray(children).some((child) => child !== "");
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { fieldVariants } from "./field.variants";
|
|
4
|
+
|
|
5
|
+
export type FieldGroupProps = ViewProps & { className?: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A stack of fields, spaced so that two fields never read as one.
|
|
9
|
+
*
|
|
10
|
+
* Its gap is the loosest step in the ladder — looser than the gap inside a
|
|
11
|
+
* field — which is the whole mechanism by which a label attaches to the control
|
|
12
|
+
* under it rather than to the one above.
|
|
13
|
+
*
|
|
14
|
+
* **Inserts no dividers**, unlike `ListGroup`. A list of rows without lines is a
|
|
15
|
+
* wall of text, but fields are already separated by whitespace and a rule
|
|
16
|
+
* between every one of them is noise. Place a `Field.Separator` where a form
|
|
17
|
+
* genuinely changes subject.
|
|
18
|
+
*/
|
|
19
|
+
export function FieldGroup({ className, ...props }: FieldGroupProps): ReactElement {
|
|
20
|
+
return <View className={fieldVariants().group({ className })} {...props} />;
|
|
21
|
+
}
|
|
22
|
+
FieldGroup.displayName = "DelacourUI.Field.Group";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useFieldContext } from "./field.context";
|
|
4
|
+
import type { FieldTextProps } from "./field.types";
|
|
5
|
+
import { fieldVariants, resolveFieldTextColor } from "./field.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The name of the control this field holds.
|
|
9
|
+
*
|
|
10
|
+
* Renders `Text.Label` and passes it a colour, never a size or a weight — the
|
|
11
|
+
* type scale belongs to the preset, and restating it here would be a second
|
|
12
|
+
* definition of `Text.Label` that could drift from it. It turns destructive with the
|
|
13
|
+
* control when the field is invalid, so the pair reads as one state.
|
|
14
|
+
*
|
|
15
|
+
* There is no `htmlFor`. React Native has no `<label>` element and no
|
|
16
|
+
* label-for-control association, which is also why there is no `Field.Title`:
|
|
17
|
+
* on the web that part exists only because a `<div>` is not a `<label>`, and
|
|
18
|
+
* here both would be the same `Text`.
|
|
19
|
+
*/
|
|
20
|
+
export function FieldLabel({ className, color, ...props }: FieldTextProps): ReactElement {
|
|
21
|
+
const field = useFieldContext();
|
|
22
|
+
const isInvalid = field?.isInvalid ?? false;
|
|
23
|
+
const isDisabled = field?.isDisabled ?? false;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Text.Label
|
|
27
|
+
className={fieldVariants({ isDisabled, isInvalid }).label({ className })}
|
|
28
|
+
color={color ?? resolveFieldTextColor("label", isInvalid)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
FieldLabel.displayName = "DelacourUI.Field.Label";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import type { FieldTextProps } from "./field.types";
|
|
4
|
+
import { type FieldLegendVariant, fieldVariants } from "./field.variants";
|
|
5
|
+
|
|
6
|
+
export type FieldLegendProps = FieldTextProps & {
|
|
7
|
+
/** `legend` titles a set; `label` matches the fields under it. */
|
|
8
|
+
variant?: FieldLegendVariant;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The title of a `Field.Set`.
|
|
13
|
+
*
|
|
14
|
+
* Two sizes, chosen by picking a `Text` preset rather than by naming a scale:
|
|
15
|
+
* `legend` renders `Text.Paragraph` with a medium weight — a heading for a
|
|
16
|
+
* section of a form — and `label` renders `Text.Label`, which is the treatment
|
|
17
|
+
* the fields below it already use. Reach for `label` when the set is nested
|
|
18
|
+
* inside another, where a second full-size heading would compete with the first.
|
|
19
|
+
*
|
|
20
|
+
* Reads no context: a legend titles a set, and a set holds no state of its own —
|
|
21
|
+
* invalid and disabled belong to each `Field` inside it. That is also why it
|
|
22
|
+
* takes no `isInvalid`; a whole section turning destructive says less than the one
|
|
23
|
+
* field that is wrong.
|
|
24
|
+
*/
|
|
25
|
+
export function FieldLegend({ className, variant = "legend", ...props }: FieldLegendProps): ReactElement {
|
|
26
|
+
const legendClassName = fieldVariants({ variant }).legend({ className });
|
|
27
|
+
|
|
28
|
+
if (variant === "label") {
|
|
29
|
+
return <Text.Label className={legendClassName} {...props} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return <Text.Paragraph className={legendClassName} {...props} />;
|
|
33
|
+
}
|
|
34
|
+
FieldLegend.displayName = "DelacourUI.Field.Legend";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { Separator } from "../separator";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import { TextClassProvider } from "../text/text.context";
|
|
6
|
+
import { fieldVariants } from "./field.variants";
|
|
7
|
+
|
|
8
|
+
export type FieldSeparatorProps = Omit<ViewProps, "children"> & {
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Optional label, drawn between two rules rather than on top of one. */
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A rule between two sections of a form, optionally labelled.
|
|
16
|
+
*
|
|
17
|
+
* With children it draws **two rules with the label between them**, not one rule
|
|
18
|
+
* with the label sitting on top. The web version absolutely-positions a single
|
|
19
|
+
* rule and punches a hole in it with an opaque `bg-background` label, which is
|
|
20
|
+
* only invisible while the separator sits on exactly that colour — put it on a
|
|
21
|
+
* card, a sheet or a tinted section and the hole shows as a block of the wrong
|
|
22
|
+
* shade. Two rules and a gap assume nothing about what is behind them.
|
|
23
|
+
*
|
|
24
|
+
* Hidden from assistive technology when it is unlabelled, for the reason
|
|
25
|
+
* `Separator` itself is: a bare line carries nothing a screen reader can use. A
|
|
26
|
+
* labelled one keeps its text, which is the part worth announcing.
|
|
27
|
+
*/
|
|
28
|
+
export function FieldSeparator({ className, children, ...props }: FieldSeparatorProps): ReactElement {
|
|
29
|
+
const slots = fieldVariants();
|
|
30
|
+
const lineClassName = slots.separatorLine();
|
|
31
|
+
|
|
32
|
+
if (!hasContent(children)) {
|
|
33
|
+
return <Separator className={className} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View className={slots.separator({ className })} {...props}>
|
|
38
|
+
<Separator className={lineClassName} />
|
|
39
|
+
<TextClassProvider value={undefined}>{wrapTextChildren(children)}</TextClassProvider>
|
|
40
|
+
<Separator className={lineClassName} />
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
FieldSeparator.displayName = "DelacourUI.Field.Separator";
|
|
45
|
+
|
|
46
|
+
/** Whether there is a label to make room for between the rules. */
|
|
47
|
+
function hasContent(children: ReactNode): boolean {
|
|
48
|
+
return Children.toArray(children).some((child) => child !== "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wraps a bare string label in a `Text.Caption`.
|
|
53
|
+
*
|
|
54
|
+
* React Native cannot render a string outside a `<Text>`, and
|
|
55
|
+
* `<Field.Separator>Or</Field.Separator>` is the shortest thing anyone will
|
|
56
|
+
* write. Consecutive strings collapse into one, so an interpolated label is a
|
|
57
|
+
* single run rather than several spaced apart by the row's own gap.
|
|
58
|
+
*/
|
|
59
|
+
function wrapTextChildren(children: ReactNode): ReactNode {
|
|
60
|
+
const items = Children.toArray(children);
|
|
61
|
+
const output: ReactNode[] = [];
|
|
62
|
+
let run: (string | number)[] = [];
|
|
63
|
+
|
|
64
|
+
const flushRun = () => {
|
|
65
|
+
if (run.length === 0) return;
|
|
66
|
+
output.push(<Text.Caption key={`label-${output.length}`}>{run.join("")}</Text.Caption>);
|
|
67
|
+
run = [];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (const child of items) {
|
|
71
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
72
|
+
run.push(child);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
flushRun();
|
|
76
|
+
output.push(child);
|
|
77
|
+
}
|
|
78
|
+
flushRun();
|
|
79
|
+
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { fieldVariants } from "./field.variants";
|
|
4
|
+
|
|
5
|
+
export type FieldSetProps = ViewProps & { className?: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A named section of a form — a legend, an optional description, and the fields
|
|
9
|
+
* under them.
|
|
10
|
+
*
|
|
11
|
+
* The step above `Field.Group`: a group is spacing and a set is meaning. Reach
|
|
12
|
+
* for a set when the fields share a heading a reader needs, and for a bare group
|
|
13
|
+
* when they are simply a list.
|
|
14
|
+
*
|
|
15
|
+
* Holds no state. Invalid and disabled belong to each `Field`, because a section
|
|
16
|
+
* that turned destructive as a whole would say less than the one field that is
|
|
17
|
+
* actually wrong.
|
|
18
|
+
*/
|
|
19
|
+
export function FieldSet({ className, ...props }: FieldSetProps): ReactElement {
|
|
20
|
+
return <View className={fieldVariants().set({ className })} {...props} />;
|
|
21
|
+
}
|
|
22
|
+
FieldSet.displayName = "DelacourUI.Field.Set";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { FieldOrientation } from "./field.variants";
|
|
3
|
+
|
|
4
|
+
export type FieldContextValue = {
|
|
5
|
+
/** Whether the field reports an invalid value. */
|
|
6
|
+
isInvalid: boolean;
|
|
7
|
+
/** Whether the field's control is unavailable. */
|
|
8
|
+
isDisabled: boolean;
|
|
9
|
+
/** Which way the field's parts are laid out. */
|
|
10
|
+
orientation: FieldOrientation;
|
|
11
|
+
/**
|
|
12
|
+
* A control inside offers its press here, so the whole row can drive it.
|
|
13
|
+
*
|
|
14
|
+
* The counterpart of `isInvalid` travelling the other way: state cascades
|
|
15
|
+
* down, and the one thing a control can hand back up is "tapping my label
|
|
16
|
+
* should do this". Pass `null` on unmount. A field holds one control, so a
|
|
17
|
+
* second registration replaces the first.
|
|
18
|
+
*/
|
|
19
|
+
registerPress: (press: (() => void) | null) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const FieldContext = createContext<FieldContextValue | null>(null);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Supplies the enclosing field's state to its subtree.
|
|
26
|
+
*
|
|
27
|
+
* This is the whole reason `Field` has a context rather than a set of classes.
|
|
28
|
+
* On the web, shadcn cascades a field's invalid state to its control with
|
|
29
|
+
* `group-data-[invalid=true]/field:` — a parent-scoped selector. Uniwind has no
|
|
30
|
+
* equivalent: its compiler reads `data-*` off a single flat selector and its
|
|
31
|
+
* runtime matches them against the props of **the same component**, so a class
|
|
32
|
+
* on a `Field` cannot reach the `Input` inside it. A context can.
|
|
33
|
+
*
|
|
34
|
+
* So `<Field isInvalid>` turns the control inside it destructive with nothing said at
|
|
35
|
+
* the control, and the same channel will serve `Checkbox` and `Radio` when they
|
|
36
|
+
* land. An explicit prop on the control still wins — see `Input`'s ladder.
|
|
37
|
+
*
|
|
38
|
+
* Lives in its own module, importing nothing but React and a type, so a
|
|
39
|
+
* component in another folder can read it without importing `../field` and
|
|
40
|
+
* closing a cycle. See AGENTS.md rule 3.
|
|
41
|
+
*
|
|
42
|
+
* **There is no `useFieldPart` here**, unlike every other compound in this
|
|
43
|
+
* package, because no part of this one needs a `Field` to work. A description
|
|
44
|
+
* under a `Field.Set` describes the whole set, and a label under one is a
|
|
45
|
+
* section heading — both are compositions the web kit documents, and a hook that
|
|
46
|
+
* threw would turn them into a red box. Every part reads
|
|
47
|
+
* {@link useFieldContext} and falls back to "not invalid, not disabled", so
|
|
48
|
+
* state applies where there is a field and nothing happens where there is not.
|
|
49
|
+
*/
|
|
50
|
+
export function FieldProvider({ value, children }: { value: FieldContextValue; children: ReactNode }): ReactElement {
|
|
51
|
+
return <FieldContext value={value}>{children}</FieldContext>;
|
|
52
|
+
}
|
|
53
|
+
FieldProvider.displayName = "DelacourUI.Field.Provider";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The enclosing field's state, or null outside a `<Field>`.
|
|
57
|
+
*
|
|
58
|
+
* This is the export a control reads. It is nullable because a control has to
|
|
59
|
+
* work perfectly well on its own — a `Field` is a layout a caller opts into, not
|
|
60
|
+
* a wrapper anything requires.
|
|
61
|
+
*/
|
|
62
|
+
export function useFieldContext(): FieldContextValue | null {
|
|
63
|
+
return use(FieldContext);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads the enclosing field's state.
|
|
68
|
+
*
|
|
69
|
+
* For a custom part that has to match the field it sits in. Throws outside one —
|
|
70
|
+
* use {@link useFieldContext} where the field is optional, as every control does.
|
|
71
|
+
*/
|
|
72
|
+
export function useField(): FieldContextValue {
|
|
73
|
+
const context = useFieldContext();
|
|
74
|
+
if (!context) {
|
|
75
|
+
throw new Error("useField must be called inside a <Field>.");
|
|
76
|
+
}
|
|
77
|
+
return context;
|
|
78
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { Pressable } from "../pressable";
|
|
4
|
+
import { type FieldContextValue, FieldProvider } from "./field.context";
|
|
5
|
+
import { type FieldOrientation, fieldVariants, resolveFieldInteractive } from "./field.variants";
|
|
6
|
+
import { FieldContent } from "./field-content";
|
|
7
|
+
import { FieldDescription } from "./field-description";
|
|
8
|
+
import { FieldError } from "./field-error";
|
|
9
|
+
import { FieldGroup } from "./field-group";
|
|
10
|
+
import { FieldLabel } from "./field-label";
|
|
11
|
+
import { FieldLegend } from "./field-legend";
|
|
12
|
+
import { FieldSeparator } from "./field-separator";
|
|
13
|
+
import { FieldSet } from "./field-set";
|
|
14
|
+
|
|
15
|
+
export type FieldProps = ViewProps & {
|
|
16
|
+
/** `vertical` stacks label over control; `horizontal` puts them side by side. */
|
|
17
|
+
orientation?: FieldOrientation;
|
|
18
|
+
/** Reports an invalid value. The label and the control inside both turn destructive. */
|
|
19
|
+
isInvalid?: boolean;
|
|
20
|
+
/** The control inside is unavailable. Fades the label and disables the control. */
|
|
21
|
+
isDisabled?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function FieldRoot({
|
|
26
|
+
orientation = "vertical",
|
|
27
|
+
isInvalid = false,
|
|
28
|
+
isDisabled = false,
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
}: FieldProps): ReactElement {
|
|
32
|
+
// State rather than a ref, because the answer changes what the row renders
|
|
33
|
+
// as. A control registers in an effect, so this costs one extra render on
|
|
34
|
+
// mount and none afterwards — the registered callback is stable.
|
|
35
|
+
const [controlPress, setControlPress] = useState<(() => void) | null>(null);
|
|
36
|
+
|
|
37
|
+
// The updater form would call `press` instead of storing it.
|
|
38
|
+
const registerPress = useCallback((press: (() => void) | null) => setControlPress(() => press), []);
|
|
39
|
+
|
|
40
|
+
const context = useMemo<FieldContextValue>(
|
|
41
|
+
() => ({ isDisabled, isInvalid, orientation, registerPress }),
|
|
42
|
+
[isDisabled, isInvalid, orientation, registerPress]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const rootClassName = fieldVariants({ isDisabled, isInvalid, orientation }).root({ className });
|
|
46
|
+
|
|
47
|
+
// A field is layout until a control asks for the row — see `resolveFieldInteractive`.
|
|
48
|
+
if (!resolveFieldInteractive(controlPress)) {
|
|
49
|
+
return (
|
|
50
|
+
<FieldProvider value={context}>
|
|
51
|
+
<View accessibilityRole="none" className={rootClassName} {...props} />
|
|
52
|
+
</FieldProvider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<FieldProvider value={context}>
|
|
58
|
+
<Pressable
|
|
59
|
+
// The control inside is the accessibility element, not this row.
|
|
60
|
+
// Without it a screen reader would announce the label twice and
|
|
61
|
+
// offer a wrapper with no state of its own.
|
|
62
|
+
accessible={false}
|
|
63
|
+
className={rootClassName}
|
|
64
|
+
disabled={isDisabled}
|
|
65
|
+
feedback="none"
|
|
66
|
+
onPress={controlPress}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
</FieldProvider>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One control, with its label, its description and its error.
|
|
75
|
+
*
|
|
76
|
+
* The layout every form in this library repeats, done once — and the one place a
|
|
77
|
+
* field's state is written down. `<Field isInvalid>` turns the label destructive
|
|
78
|
+
* **and the control inside it too**, with nothing said at the control: `Input`
|
|
79
|
+
* and `Checkbox` both read this field's context, and `Radio` will read the same one.
|
|
80
|
+
*
|
|
81
|
+
* That cascade is a React context rather than a class because it has to be.
|
|
82
|
+
* Uniwind's data selectors match against the props of the component carrying the
|
|
83
|
+
* class, so there is no parent-scoped `group-data-*` for a `Field` to style its
|
|
84
|
+
* control with. See `field.context.tsx`.
|
|
85
|
+
*
|
|
86
|
+
* A control's own prop still wins, so one field inside an invalid group can opt
|
|
87
|
+
* out with `<Input isInvalid={false} />`.
|
|
88
|
+
*
|
|
89
|
+
* **The whole row drives the control**, once one offers a press through the same
|
|
90
|
+
* context — so tapping "Accept the terms", or the description under it, ticks
|
|
91
|
+
* the `Checkbox` beside it. A checkbox in a form is a small square next to a
|
|
92
|
+
* sentence, and the sentence is the part people aim at. The row is a `Pressable`
|
|
93
|
+
* with `feedback="none"` only while a control has registered; a field of static
|
|
94
|
+
* text mounts no gesture detector at all.
|
|
95
|
+
*
|
|
96
|
+
* The text parts render the `Text` presets and pass a colour, never a size —
|
|
97
|
+
* `Field.Label` *is* `Text.Label`. There is no `Field.Title`: on the web it
|
|
98
|
+
* exists because a `<div>` is not a `<label>`, and React Native has neither.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* <Field>
|
|
102
|
+
* <Field.Label>Full name</Field.Label>
|
|
103
|
+
* <Input placeholder="Ada Lovelace" />
|
|
104
|
+
* <Field.Description>This appears on invoices.</Field.Description>
|
|
105
|
+
* </Field>
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* <Field isInvalid={!isValid}>
|
|
109
|
+
* <Field.Label>Email</Field.Label>
|
|
110
|
+
* <Input inputMode="email" onChangeText={setEmail} value={email} />
|
|
111
|
+
* <Field.Error>{error}</Field.Error>
|
|
112
|
+
* </Field>
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* <Field.Set>
|
|
116
|
+
* <Field.Legend>Profile</Field.Legend>
|
|
117
|
+
* <Field.Description>Shown on invoices and emails.</Field.Description>
|
|
118
|
+
* <Field.Group>
|
|
119
|
+
* <Field>
|
|
120
|
+
* <Field.Label>Username</Field.Label>
|
|
121
|
+
* <Input autoCapitalize="none" />
|
|
122
|
+
* </Field>
|
|
123
|
+
* </Field.Group>
|
|
124
|
+
* </Field.Set>
|
|
125
|
+
*/
|
|
126
|
+
export const Field = Object.assign(FieldRoot, {
|
|
127
|
+
/** A named section of a form — a legend, a description, and the fields under them. */
|
|
128
|
+
Set: FieldSet,
|
|
129
|
+
/** The title of a `Field.Set`. `variant="label"` for a nested one. */
|
|
130
|
+
Legend: FieldLegend,
|
|
131
|
+
/** A stack of fields, spaced so two never read as one. Inserts no dividers. */
|
|
132
|
+
Group: FieldGroup,
|
|
133
|
+
/** The control's name. Turns destructive with the control when the field is invalid. */
|
|
134
|
+
Label: FieldLabel,
|
|
135
|
+
/** Supporting copy. Stays muted in every state, so an error is the line that changed. */
|
|
136
|
+
Description: FieldDescription,
|
|
137
|
+
/** What is wrong with the value. Renders nothing when it has no children. */
|
|
138
|
+
Error: FieldError,
|
|
139
|
+
/** A label and its description as one block, for a horizontal field. */
|
|
140
|
+
Content: FieldContent,
|
|
141
|
+
/** A rule between sections, optionally labelled between two rules. */
|
|
142
|
+
Separator: FieldSeparator,
|
|
143
|
+
displayName: "DelacourUI.Field",
|
|
144
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TextProps } from "../text";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape of a field's text parts.
|
|
5
|
+
*
|
|
6
|
+
* Shared by `Field.Label`, `Field.Description`, `Field.Error` and
|
|
7
|
+
* `Field.Legend`, which differ only in the preset they render and the colour
|
|
8
|
+
* they resolve, so it lives in a leaf rather than in one of the four files
|
|
9
|
+
* arbitrarily.
|
|
10
|
+
*
|
|
11
|
+
* `variant` is omitted because each part already is one — naming a second would
|
|
12
|
+
* let a caller turn a description into a heading and lose the type scale the
|
|
13
|
+
* component exists to keep.
|
|
14
|
+
*/
|
|
15
|
+
export type FieldTextProps = Omit<TextProps, "variant">;
|