@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,38 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
3
|
+
import type { ChartResolvedSeries } from "./chart.types";
|
|
4
|
+
import { applyChartColors, CHART_MAX_TOKEN_SERIES, partitionChartColors } from "./chart.variants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves a series list's theme tokens into colour strings.
|
|
8
|
+
*
|
|
9
|
+
* Every theme lookup happens here, above the canvas. Skia's renderer is a
|
|
10
|
+
* second React reconciler with no Uniwind provider in it, so a hook called
|
|
11
|
+
* inside would resolve nothing — the marks receive resolved strings instead.
|
|
12
|
+
* A hook rather than a helper in a root so `Chart` and `PieChart` share one
|
|
13
|
+
* palette and one cap.
|
|
14
|
+
*
|
|
15
|
+
* Eight calls, written out. `partitionChartColors` pads its token list to
|
|
16
|
+
* exactly this many so the hook count cannot vary between renders, and a loop
|
|
17
|
+
* over the series would break the rules of hooks the moment a series was
|
|
18
|
+
* added. The repetition is the mechanism, not an oversight. Tokens are
|
|
19
|
+
* deduped first, so the eight are eight *distinct* tokens — the five-token
|
|
20
|
+
* ramp costs five, however many series walk it.
|
|
21
|
+
*/
|
|
22
|
+
export function useChartPalette(declared: readonly ChartResolvedSeries[]): ChartResolvedSeries[] {
|
|
23
|
+
const partition = useMemo(() => partitionChartColors(declared, CHART_MAX_TOKEN_SERIES), [declared]);
|
|
24
|
+
|
|
25
|
+
const color0 = useThemeColor(partition.tokens[0] as string);
|
|
26
|
+
const color1 = useThemeColor(partition.tokens[1] as string);
|
|
27
|
+
const color2 = useThemeColor(partition.tokens[2] as string);
|
|
28
|
+
const color3 = useThemeColor(partition.tokens[3] as string);
|
|
29
|
+
const color4 = useThemeColor(partition.tokens[4] as string);
|
|
30
|
+
const color5 = useThemeColor(partition.tokens[5] as string);
|
|
31
|
+
const color6 = useThemeColor(partition.tokens[6] as string);
|
|
32
|
+
const color7 = useThemeColor(partition.tokens[7] as string);
|
|
33
|
+
|
|
34
|
+
return useMemo(
|
|
35
|
+
() => applyChartColors(declared, partition, [color0, color1, color2, color3, color4, color5, color6, color7]),
|
|
36
|
+
[declared, partition, color0, color1, color2, color3, color4, color5, color6, color7]
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Checkbox
|
|
2
|
+
|
|
3
|
+
A box that is ticked or not — alone, or as one of a group sharing a value list.
|
|
4
|
+
Root plus `Checkbox.Label` and `Checkbox.Group`.
|
|
5
|
+
|
|
6
|
+
`import { Checkbox } from "@delacour/react-native-ui/checkbox";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/checkbox` |
|
|
13
|
+
| `checkbox.tsx` | Root + the `Object.assign` compound surface |
|
|
14
|
+
| `checkbox-label.tsx` | `Checkbox.Label`, the `Text.Label` inside the tap target |
|
|
15
|
+
| `checkbox-group.tsx` | `Checkbox.Group`, which owns the checked list |
|
|
16
|
+
| `checkbox-box.tsx` | The square, its animated border, fill and tick — internal |
|
|
17
|
+
| `checkbox.context.tsx` | `CheckboxContext` and `CheckboxGroupContext`, with their hooks |
|
|
18
|
+
| `checkbox.types.ts` | Prop types shared by two or more parts |
|
|
19
|
+
| `checkbox.variants.ts` | Pure `tv()` slots + six resolvers, no RN imports |
|
|
20
|
+
| `checkbox.variants.test.ts` | |
|
|
21
|
+
|
|
22
|
+
## Design
|
|
23
|
+
|
|
24
|
+
- **Colours**: `default`, `primary`, `success`, `warning`, `destructive`, `info` —
|
|
25
|
+
[Badge](../badge/AGENTS.md)'s set, reusing tokens the theme already has. **Sizes**: `sm`, `md`, `lg`.
|
|
26
|
+
**Alignment**: `start`, `end`. There is no `variant` axis: a checkbox has one
|
|
27
|
+
shape, and a second way to paint it would be a second thing to keep in step
|
|
28
|
+
with the [radio](../radio/AGENTS.md) that will sit beside it.
|
|
29
|
+
- **The root draws the box itself**, which is what makes `<Checkbox />` a
|
|
30
|
+
complete control with no children. Anything composed inside lands *beside* the
|
|
31
|
+
box and shares its tap target — which is the entire reason `Checkbox.Label`
|
|
32
|
+
exists next to [`Field.Label`](../field/AGENTS.md). `Field.Label` names a control from a row away;
|
|
33
|
+
this one is inside the pressable, so tapping the words toggles the box. Use the
|
|
34
|
+
field's in a horizontal `Field` and this one everywhere else.
|
|
35
|
+
- **`Checkbox.Label` *is* [`Text.Label`](../text/AGENTS.md).** It renders the preset and passes a
|
|
36
|
+
step and a colour, never a class for either — `resolveCheckboxLabelSize` maps
|
|
37
|
+
the checkbox's own step names onto `TEXT_SIZES`', and
|
|
38
|
+
`resolveCheckboxLabelColor` returns `undefined` to mean "leave the preset's own
|
|
39
|
+
alone". The `label` slot carries layout and nothing else, and a test asserts it
|
|
40
|
+
holds no `text-*` or `font-*` at any size. Same rule as [`Field`](../field/AGENTS.md), same reason
|
|
41
|
+
[`Input`](../input/AGENTS.md) ships no label part at all.
|
|
42
|
+
- **`color` paints the indicator, not the box.** The indicator is an
|
|
43
|
+
absolute-fill layer that is invisible until the box is ticked, so an unticked
|
|
44
|
+
box is `border-input bg-card` at every colour — the same chrome a field wears,
|
|
45
|
+
because it is the same kind of thing. Only the border has to know both states,
|
|
46
|
+
which is why the six `color × isFilled` cells are the whole of
|
|
47
|
+
`compoundVariants` instead of a thirty-six cell matrix. The border always
|
|
48
|
+
matches its own fill, and a test pins the pair rather than trusting two maps.
|
|
49
|
+
- **`isFilled`, not `isChecked`, is the tv axis.** Checked and indeterminate both
|
|
50
|
+
paint the surface and only the glyph tells them apart, so the axis is named for
|
|
51
|
+
what it does and `resolveCheckboxFilled` is the translation. Indeterminate also
|
|
52
|
+
reports `checked="mixed"`, so a "select all" row says what it means rather than
|
|
53
|
+
claiming a half-truth.
|
|
54
|
+
- **Invalid outranks the colour**, on the border and the fill, ticked or not.
|
|
55
|
+
A checkbox that stayed green while its value was rejected would drop the only
|
|
56
|
+
signal it has, exactly while the value is being corrected — the precedence
|
|
57
|
+
[`Input`](../input/AGENTS.md) sets between invalid and focus.
|
|
58
|
+
- **The axis ladder is `own ?? group ?? field ?? default`, and it is deliberately
|
|
59
|
+
not [`Input`](../input/AGENTS.md)'s.** `Input.Group` puts itself *first* because it owns the one box
|
|
60
|
+
a grouped field renders into: two answers to one question is not a state worth
|
|
61
|
+
expressing. `Checkbox.Group` owns no box. It is a state controller that also
|
|
62
|
+
carries shared defaults, which makes it the same kind of thing as [`Field`](../field/AGENTS.md) — a
|
|
63
|
+
wrapper a control overrides — so "make the group `lg`" and "make this one
|
|
64
|
+
`destructive`" are different questions and both get an answer. `??` throughout and
|
|
65
|
+
never `||`, so `isDisabled={false}` opts a child out of a disabled group.
|
|
66
|
+
- **`Checkbox.Group`'s state is one array of the children's `value`s.**
|
|
67
|
+
`toggleCheckedValue` is the whole transition and it is pure, so `bun test`
|
|
68
|
+
reaches it — including that it always returns a *new* array, since React bails
|
|
69
|
+
out of a re-render on an unchanged reference and a mutation would flip the
|
|
70
|
+
state while leaving the screen alone. A grouped checkbox with no `value`
|
|
71
|
+
throws by name: group membership is invisible in the child's props at compile
|
|
72
|
+
time, so it cannot be a type error.
|
|
73
|
+
- **The group is a plain `View` with no role.** A group of checkboxes is a
|
|
74
|
+
container, and announcing it as a control would put an actionless element in
|
|
75
|
+
front of every child. Lay them out any other way with a `className` —
|
|
76
|
+
`flex-row flex-wrap` for a row.
|
|
77
|
+
- **Inside a `Field` the box hands its toggle back up**, so the row is the
|
|
78
|
+
target and a form checkbox can be a bare `<Checkbox />` with the field naming
|
|
79
|
+
it, rather than a `Checkbox.Label` repeating the name. It registers a
|
|
80
|
+
ref-backed trampoline rather than the toggle itself: the toggle is a new
|
|
81
|
+
function whenever `checked` changes, and re-registering on every tick would
|
|
82
|
+
re-render the field for nothing. See [Field](../field/AGENTS.md).
|
|
83
|
+
- **The whole [`Pressable`](../pressable/AGENTS.md) surface passes through**, because the root *is* one.
|
|
84
|
+
Two defaults differ from a bare `Pressable` and only two: `feedback="fade"`,
|
|
85
|
+
since a spring on a 20pt square reads as a jitter rather than a press, and
|
|
86
|
+
`haptic="selection"`, since a checkbox is a state toggle and the tick landing
|
|
87
|
+
is the confirmation — [`Button`](../button/AGENTS.md) and [`Badge`](../badge/AGENTS.md) leave it off because their press is
|
|
88
|
+
an action, not a state change. Both are ordinary props, so `haptic={false}`
|
|
89
|
+
silences it. `onPress` is the one prop `Omit`ed rather than forwarded: the
|
|
90
|
+
press *is* the toggle, and `onCheckedChange` is where a side effect goes.
|
|
91
|
+
- **The fill, the tick and the border are three gestures off one shared value.**
|
|
92
|
+
The fill fades and scales **from the centre**: a box is filled, not slid into,
|
|
93
|
+
and there is no edge a checkbox is filled *from* — a `translateX` here reads as
|
|
94
|
+
a panel arriving rather than a surface appearing, so there is none. The tick is
|
|
95
|
+
not faded up with it; it sits behind a container whose width opens from the
|
|
96
|
+
box's left edge, so the stroke is drawn on when ticking and taken back when
|
|
97
|
+
unticking. `tickDelay` holds it until the surface it is drawn on is most of the
|
|
98
|
+
way there — starting both at once reads as one blurred event. The border comes
|
|
99
|
+
last, held by `borderDelay` until the surface is near the edge, so it reads as
|
|
100
|
+
the fill *arriving* at the border rather than as an outline changing on its
|
|
101
|
+
own. All three interpolate off a single `withTiming`, so they cannot drift, and
|
|
102
|
+
the values live in `CHECKBOX_INDICATOR_ANIMATION` where a test pins that every
|
|
103
|
+
track travels and that the filled end is a finished box rather than something
|
|
104
|
+
stopped mid-way.
|
|
105
|
+
- **The fill overlaps the border rather than meeting it, and its corner is the
|
|
106
|
+
box's own.** `-inset-px` on the `indicator` slot puts it a point past the
|
|
107
|
+
padding box on every side, so it runs *under* the border ring instead of
|
|
108
|
+
stopping against it, and `resolveCheckboxFillRadius` gives it the same corner
|
|
109
|
+
the box wears. It does not animate: `scale` shrinks the rendered corner along
|
|
110
|
+
with the square, which is what keeps a half-grown fill looking like a smaller
|
|
111
|
+
version of the finished one, and animating the radius would only make it
|
|
112
|
+
correct at one end.
|
|
113
|
+
|
|
114
|
+
**The overlap is the whole point, and it replaced the opposite rule.** The fill
|
|
115
|
+
used to be `inset-0` with the box's radius *minus* its border width — the rule
|
|
116
|
+
for two rounded rectangles to sit concentric. Concentric is exactly what went
|
|
117
|
+
wrong: the fill's outer curve and the border's inner curve became the same
|
|
118
|
+
curve, rasterised twice on two layers and antialiased independently. At a
|
|
119
|
+
corner pixel where each gives coverage `a` the composite covers `2a - a²`, so
|
|
120
|
+
`(1 - a)²` of the box's own `bg-card` bleeds through — a dull arc at each of
|
|
121
|
+
the four corners, on iOS and Android alike. The straight edges are pixel
|
|
122
|
+
aligned and showed nothing, which is what made it read as a corner artifact
|
|
123
|
+
rather than as a sizing bug. Overlapping the two removes the shared edge
|
|
124
|
+
instead of concealing it, and holds whichever way a platform orders its paint:
|
|
125
|
+
iOS draws a `CALayer` border above its sublayers, Android draws it below.
|
|
126
|
+
|
|
127
|
+
It went unnoticed until the playground opened in the house preset, whose
|
|
128
|
+
`radius: "small"` is 7.2 against the library default's 10. The seam is a fixed
|
|
129
|
+
sub-pixel width, so shrinking an `md` box's corner from 4pt to 2.88pt made the
|
|
130
|
+
same defect a third louder. Do not "tidy" the inset back to `inset-0`, or
|
|
131
|
+
restore the subtraction; a test fails by name if either happens.
|
|
132
|
+
- **It is computed from `--radius`, not written down.** Minting a token for it
|
|
133
|
+
would only give the box and the fill a second place to disagree. It cannot be
|
|
134
|
+
read back either: the corner scale is `@theme inline`, so Tailwind substitutes
|
|
135
|
+
each step into its utilities and no `--radius-xs` variable reaches the runtime
|
|
136
|
+
— though what it substitutes still carries a live `var(--radius)`, which is
|
|
137
|
+
what keeps a class-based `rounded-xs` and this function in step when a preset
|
|
138
|
+
retunes the base at runtime.
|
|
139
|
+
`resolveCheckboxFillRadius` takes the live `--radius` and applies
|
|
140
|
+
`CHECKBOX_RADIUS_MULTIPLIER`, which is why a consumer pasting a theme with a
|
|
141
|
+
different `--radius` moves the fill with the border instead of leaving it
|
|
142
|
+
behind. `checkbox.variants.test.ts` reads `tokens.css` and asserts the
|
|
143
|
+
multipliers match it, that the result *is* the box's own step at any
|
|
144
|
+
`--radius`, that `CHECKBOX_RADIUS_STEP` names the `rounded-*` the `box` slot
|
|
145
|
+
actually wears, and that the box's border really is the bare 1pt `border` the
|
|
146
|
+
`-inset-px` assumes. Retuning the scale, or reaching for `border-2`, fails the
|
|
147
|
+
build rather than quietly reopening the gap.
|
|
148
|
+
- **The border is the one part of the box no `tv()` describes.** A colour that
|
|
149
|
+
fades cannot be a class, so it interpolates between two token *values* —
|
|
150
|
+
`resolveCheckboxBorderTokens` names them, and `CHECKBOX_SURFACE_TOKEN` is the
|
|
151
|
+
same colour the `indicator` slot paints as a `bg-*`, pinned against it by a
|
|
152
|
+
test. The base keeps `border-input` as the resting appearance the animated
|
|
153
|
+
style starts from, and nothing else in the slot set mentions a border colour;
|
|
154
|
+
two sources for one border is how a class and a style end up disagreeing for a
|
|
155
|
+
frame on every toggle. An **invalid** box returns `destructive` at *both* ends, so
|
|
156
|
+
there is nothing to fade — the border is the signal the value is wrong, and it
|
|
157
|
+
has to be there before the box is ticked as much as after.
|
|
158
|
+
- **The clip is measured, not tabulated.** It needs the box's width in points and
|
|
159
|
+
`size-checkbox-md` cannot be read from JavaScript, so it comes from the fill
|
|
160
|
+
layer's own `onLayout` — that layer is already exactly the width the clip has
|
|
161
|
+
to span. A table of numbers here would be `tokens.css` restated in TypeScript,
|
|
162
|
+
which is the drift `tokens.test.ts` exists to catch everywhere else.
|
|
163
|
+
- **Reduce-motion takes Reanimated's default `System` policy here**, unlike
|
|
164
|
+
[`Spinner`](../spinner/AGENTS.md). Under it `withTiming` completes instantly, which for a checkbox is
|
|
165
|
+
right: the state change is the point and the travel is decoration. A spinner
|
|
166
|
+
had to opt out because its animation *is* the status signal.
|
|
167
|
+
- **`hitSlop` is new to this package, and only a bare box gets any.** A bare `md`
|
|
168
|
+
checkbox is a 20pt square against a 44pt minimum, with no padded capsule to
|
|
169
|
+
absorb the difference the way [`Badge.CloseButton`](../badge/AGENTS.md) has. Once there is a label
|
|
170
|
+
the row is the target and is already wide, so `resolveCheckboxHitSlop` returns
|
|
171
|
+
nothing — slop on top of that would overlap the row below and make a tap
|
|
172
|
+
between two checkboxes ambiguous, which is worse than a merely adequate target.
|
|
173
|
+
- **There is no `Checkbox.Description` and no `Checkbox.Indicator`.**
|
|
174
|
+
`Field.Description` already is the first, and a label defined twice is a type
|
|
175
|
+
scale that can drift. The second has nothing to configure that
|
|
176
|
+
`isIndeterminate` does not already decide — the glyph swap is the only choice
|
|
177
|
+
it would offer.
|
|
178
|
+
- **The box mints no scale of its own — it reads `--spacing-icon-*`, two steps
|
|
179
|
+
above its own glyph.** A checkbox *is* a glyph in a box, and both measurements
|
|
180
|
+
already sit on that scale: 18/14, 20/16, 24/18. A private
|
|
181
|
+
`--spacing-checkbox-*` would be three numbers that have to be retuned in step
|
|
182
|
+
with three others forever, and nothing would notice when they stopped
|
|
183
|
+
agreeing. The two-step offset is what leaves the tick breathing room, and a
|
|
184
|
+
test pins the *offset* rather than the points, so the icon scale can be
|
|
185
|
+
retuned without the test becoming a transcript of it.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
cancelAnimation,
|
|
5
|
+
Extrapolation,
|
|
6
|
+
interpolate,
|
|
7
|
+
interpolateColor,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useSharedValue,
|
|
10
|
+
withTiming,
|
|
11
|
+
} from "react-native-reanimated";
|
|
12
|
+
import { useCSSVariable } from "uniwind";
|
|
13
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
14
|
+
import { IconCheckmark1Small, IconMinusSmall } from "../../icons/central";
|
|
15
|
+
import { Icon } from "../icon";
|
|
16
|
+
import { useCheckboxPart } from "./checkbox.context";
|
|
17
|
+
import {
|
|
18
|
+
CHECKBOX_BORDER_WIDTH,
|
|
19
|
+
CHECKBOX_GLYPH_TOKEN,
|
|
20
|
+
CHECKBOX_INDICATOR_ANIMATION,
|
|
21
|
+
CHECKBOX_INVALID_GLYPH_TOKEN,
|
|
22
|
+
checkboxVariants,
|
|
23
|
+
resolveCheckboxBorderTokens,
|
|
24
|
+
resolveCheckboxFilled,
|
|
25
|
+
resolveCheckboxFillRadius,
|
|
26
|
+
} from "./checkbox.variants";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The square itself: its border, the fill behind it and the tick drawn on top.
|
|
30
|
+
*
|
|
31
|
+
* Internal — the checkbox renders one and there is nothing for a caller to
|
|
32
|
+
* compose here that `isIndeterminate` does not already decide. It takes a file
|
|
33
|
+
* of its own because it owns every animated value; keeping them in the root
|
|
34
|
+
* would make the root a component that changes for two unrelated reasons.
|
|
35
|
+
*
|
|
36
|
+
* Three things move, off one shared progress, so they cannot drift out of step:
|
|
37
|
+
*
|
|
38
|
+
* - the **fill** fades and scales from the centre. A box is filled, not slid
|
|
39
|
+
* into, so it arrives from no edge. Its corner radius is fixed rather than
|
|
40
|
+
* animated — {@link resolveCheckboxFillRadius} gives it the box's own corner at
|
|
41
|
+
* every scale, and the transform shrinks the rendered one with it. It spans the
|
|
42
|
+
* whole border box rather than the padding box, so it runs *under* the border
|
|
43
|
+
* ring instead of stopping against it; two coincident antialiased curves leave
|
|
44
|
+
* a seam at each corner, and there is no longer a shared edge to leave one.
|
|
45
|
+
* - the **tick** is clipped by a container whose width opens from the left, so
|
|
46
|
+
* the stroke is drawn on when ticking and taken back when unticking rather
|
|
47
|
+
* than faded up in place.
|
|
48
|
+
* - the **border** interpolates from the field chrome to the fill's own colour,
|
|
49
|
+
* held back by `borderDelay` until the surface is near the edge — so it reads
|
|
50
|
+
* as the fill arriving at the border rather than as an outline changing on its
|
|
51
|
+
* own. A colour being interpolated cannot be a class, which is why this one is
|
|
52
|
+
* the only part of the box a `tv()` does not describe.
|
|
53
|
+
*
|
|
54
|
+
* The clip needs the box's width in points, and a `size-checkbox-*` class cannot
|
|
55
|
+
* be read from JavaScript. It comes from the box's own `onLayout` rather than a
|
|
56
|
+
* table of numbers restating `tokens.css`, less its two borders — `onLayout`
|
|
57
|
+
* reports the border box, and the clip is positioned in the padding box inside
|
|
58
|
+
* it. The fill cannot be the thing measured: it deliberately overhangs.
|
|
59
|
+
*
|
|
60
|
+
* Reduce-motion takes Reanimated's default `System` policy here, deliberately
|
|
61
|
+
* unlike `Spinner`. Under it `withTiming` completes instantly, which for a
|
|
62
|
+
* checkbox is exactly right: the state change is the point and the travel is
|
|
63
|
+
* decoration. A spinner had to opt out because its animation *is* the signal.
|
|
64
|
+
*/
|
|
65
|
+
export function CheckboxBox(): ReactElement {
|
|
66
|
+
const { color, size, isChecked, isIndeterminate, isInvalid } = useCheckboxPart("Checkbox.Box");
|
|
67
|
+
const isFilled = resolveCheckboxFilled({ isChecked, isIndeterminate });
|
|
68
|
+
|
|
69
|
+
const progress = useSharedValue(isFilled ? 1 : 0);
|
|
70
|
+
const boxWidth = useSharedValue(0);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
progress.value = withTiming(isFilled ? 1 : 0, { duration: CHECKBOX_INDICATOR_ANIMATION.durationMs });
|
|
74
|
+
|
|
75
|
+
// Without this a box unmounted mid-toggle leaves its timing running.
|
|
76
|
+
return () => cancelAnimation(progress);
|
|
77
|
+
}, [isFilled, progress]);
|
|
78
|
+
|
|
79
|
+
// Subtracting the border twice is what keeps the glyph on the box's centre
|
|
80
|
+
// line. `tick` and `tickInner` are positioned in the padding box, so handing
|
|
81
|
+
// them the border-box width moves the centre they resolve against.
|
|
82
|
+
const handleLayout = useCallback(
|
|
83
|
+
(event: LayoutChangeEvent) => {
|
|
84
|
+
boxWidth.value = Math.max(0, event.nativeEvent.layout.width - 2 * CHECKBOX_BORDER_WIDTH);
|
|
85
|
+
},
|
|
86
|
+
[boxWidth]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// `--radius` is the only step of the corner scale that survives to runtime —
|
|
90
|
+
// the rest are `@theme inline` and get substituted into their utilities — so
|
|
91
|
+
// the fill's own corner is computed from the base rather than read back.
|
|
92
|
+
const radius = (useCSSVariable("--radius") as number | undefined) ?? 0;
|
|
93
|
+
const fillRadius = resolveCheckboxFillRadius(size, radius);
|
|
94
|
+
|
|
95
|
+
const border = resolveCheckboxBorderTokens({ color, isInvalid });
|
|
96
|
+
const restBorderColor = useThemeColor(border.rest) ?? "transparent";
|
|
97
|
+
const activeBorderColor = useThemeColor(border.active) ?? "transparent";
|
|
98
|
+
|
|
99
|
+
const boxStyle = useAnimatedStyle(() => ({
|
|
100
|
+
borderColor: interpolateColor(
|
|
101
|
+
interpolate(progress.value, [CHECKBOX_INDICATOR_ANIMATION.borderDelay, 1], [0, 1], Extrapolation.CLAMP),
|
|
102
|
+
[0, 1],
|
|
103
|
+
[restBorderColor, activeBorderColor]
|
|
104
|
+
),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const fillStyle = useAnimatedStyle(() => ({
|
|
108
|
+
opacity: interpolate(progress.value, [0, 1], CHECKBOX_INDICATOR_ANIMATION.opacity),
|
|
109
|
+
transform: [{ scale: interpolate(progress.value, [0, 1], CHECKBOX_INDICATOR_ANIMATION.scale) }],
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
// Clamped, so the tick sits at zero width through the delay rather than being
|
|
113
|
+
// extrapolated to a negative one.
|
|
114
|
+
const tickStyle = useAnimatedStyle(() => ({
|
|
115
|
+
width:
|
|
116
|
+
boxWidth.value *
|
|
117
|
+
interpolate(progress.value, [CHECKBOX_INDICATOR_ANIMATION.tickDelay, 1], [0, 1], Extrapolation.CLAMP),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// Full width regardless of the clip in front of it, so the glyph stays on the
|
|
121
|
+
// box's centre line while the clip opens instead of sliding across with it.
|
|
122
|
+
const tickInnerStyle = useAnimatedStyle(() => ({ width: boxWidth.value }));
|
|
123
|
+
|
|
124
|
+
const slots = checkboxVariants({ color, isFilled, isInvalid, size });
|
|
125
|
+
// A colour that has to reach an SVG paint prop cannot be a class. See Theming.
|
|
126
|
+
const glyphColor = useThemeColor(isInvalid ? CHECKBOX_INVALID_GLYPH_TOKEN : CHECKBOX_GLYPH_TOKEN[color]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Animated.View className={slots.box()} onLayout={handleLayout} style={boxStyle}>
|
|
130
|
+
<Animated.View
|
|
131
|
+
className={slots.indicator()}
|
|
132
|
+
// A fixed radius rather than an animated one: the fill wears the box's
|
|
133
|
+
// own corner, and that value does not change while the box is growing.
|
|
134
|
+
// `scale` shrinks the rendered corner along with the square, which is
|
|
135
|
+
// what keeps a half-grown fill looking like a smaller version of the
|
|
136
|
+
// finished one. It is read from `--radius` rather than written down
|
|
137
|
+
// because a consumer's theme is allowed to retune that.
|
|
138
|
+
style={[{ borderRadius: fillRadius }, fillStyle]}
|
|
139
|
+
/>
|
|
140
|
+
<Animated.View className={slots.tick()} style={tickStyle}>
|
|
141
|
+
<Animated.View className={slots.tickInner()} style={tickInnerStyle}>
|
|
142
|
+
<Icon
|
|
143
|
+
className={slots.glyph()}
|
|
144
|
+
color={glyphColor}
|
|
145
|
+
icon={isIndeterminate ? IconMinusSmall : IconCheckmark1Small}
|
|
146
|
+
/>
|
|
147
|
+
</Animated.View>
|
|
148
|
+
</Animated.View>
|
|
149
|
+
</Animated.View>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
CheckboxBox.displayName = "DelacourUI.Checkbox.Box";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback, useMemo } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { useControllableState } from "../../hooks/use-controllable-state";
|
|
4
|
+
import { type CheckboxGroupContextValue, CheckboxGroupProvider } from "./checkbox.context";
|
|
5
|
+
import {
|
|
6
|
+
type CheckboxAlignment,
|
|
7
|
+
type CheckboxColor,
|
|
8
|
+
type CheckboxSize,
|
|
9
|
+
checkboxVariants,
|
|
10
|
+
toggleCheckedValue,
|
|
11
|
+
} from "./checkbox.variants";
|
|
12
|
+
|
|
13
|
+
export type CheckboxGroupProps = Omit<ViewProps, "children"> & {
|
|
14
|
+
/** The `value` of every checked child. Controlled. */
|
|
15
|
+
checked?: string[];
|
|
16
|
+
/** The values checked to begin with, while uncontrolled. */
|
|
17
|
+
defaultChecked?: string[];
|
|
18
|
+
/** Fired with the whole list every time a child is toggled. */
|
|
19
|
+
onChecked?: (checked: string[]) => void;
|
|
20
|
+
/** Defaults every child takes unless it names its own. */
|
|
21
|
+
color?: CheckboxColor;
|
|
22
|
+
size?: CheckboxSize;
|
|
23
|
+
alignment?: CheckboxAlignment;
|
|
24
|
+
isInvalid?: boolean;
|
|
25
|
+
isDisabled?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function CheckboxGroupRoot({
|
|
31
|
+
checked,
|
|
32
|
+
defaultChecked = EMPTY,
|
|
33
|
+
onChecked,
|
|
34
|
+
color,
|
|
35
|
+
size,
|
|
36
|
+
alignment,
|
|
37
|
+
isInvalid,
|
|
38
|
+
isDisabled,
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
...props
|
|
42
|
+
}: CheckboxGroupProps): ReactElement {
|
|
43
|
+
const [values, setValues] = useControllableState<string[]>({
|
|
44
|
+
defaultValue: defaultChecked,
|
|
45
|
+
onChange: onChecked,
|
|
46
|
+
value: checked,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const toggle = useCallback((value: string) => setValues(toggleCheckedValue(values, value)), [setValues, values]);
|
|
50
|
+
|
|
51
|
+
const context = useMemo<CheckboxGroupContextValue>(
|
|
52
|
+
() => ({ alignment, checked: values, color, isDisabled, isInvalid, size, toggle }),
|
|
53
|
+
[alignment, color, isDisabled, isInvalid, size, toggle, values]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<CheckboxGroupProvider value={context}>
|
|
58
|
+
<View className={checkboxVariants().group({ className })} {...props}>
|
|
59
|
+
{children}
|
|
60
|
+
</View>
|
|
61
|
+
</CheckboxGroupProvider>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A stable empty list, so an uncontrolled group does not seed its state from a
|
|
67
|
+
* fresh array on every render.
|
|
68
|
+
*/
|
|
69
|
+
const EMPTY: string[] = [];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The checked list for the checkboxes inside it, and the axes they share.
|
|
73
|
+
*
|
|
74
|
+
* State is one array of the children's `value`s: `checked={["email"]}` ticks the
|
|
75
|
+
* box called `email`, and `onChecked` fires with the whole new list each time
|
|
76
|
+
* one is toggled. Controlled or uncontrolled from the same hook — pass `checked`
|
|
77
|
+
* to own it, or `defaultChecked` and let the group hold it.
|
|
78
|
+
*
|
|
79
|
+
* **The axes here are defaults, not overrides.** A child's own `color` or `size`
|
|
80
|
+
* wins, and so does an explicit `isDisabled={false}` under a disabled group.
|
|
81
|
+
* That is the opposite of `Input.Group`, and the difference is what each owns:
|
|
82
|
+
* `Input.Group` *is* the box its field renders into, so the axes that draw one
|
|
83
|
+
* can only have a single answer. A `Checkbox.Group` owns no box — it is a
|
|
84
|
+
* wrapper supplying context, the same kind of thing as `Field`, and a control
|
|
85
|
+
* overrides one of those.
|
|
86
|
+
*
|
|
87
|
+
* A plain `View`, with no role. A group of checkboxes is a container, and
|
|
88
|
+
* announcing it as a control would put an element with no action in front of
|
|
89
|
+
* every child. Lay them out any other way with a `className` —
|
|
90
|
+
* `flex-row flex-wrap` for a row.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* <Checkbox.Group checked={channels} onChecked={setChannels}>
|
|
94
|
+
* <Checkbox value="email">Email</Checkbox>
|
|
95
|
+
* <Checkbox value="sms">SMS</Checkbox>
|
|
96
|
+
* <Checkbox value="push">Push</Checkbox>
|
|
97
|
+
* </Checkbox.Group>
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* <Checkbox.Group color="success" defaultChecked={["daily"]} size="lg">
|
|
101
|
+
* <Checkbox value="daily">Daily digest</Checkbox>
|
|
102
|
+
* <Checkbox color="destructive" value="alerts">Incident alerts</Checkbox>
|
|
103
|
+
* </Checkbox.Group>
|
|
104
|
+
*/
|
|
105
|
+
export const CheckboxGroup = Object.assign(CheckboxGroupRoot, {
|
|
106
|
+
displayName: "DelacourUI.Checkbox.Group",
|
|
107
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useCheckboxPart } from "./checkbox.context";
|
|
4
|
+
import type { CheckboxLabelProps } from "./checkbox.types";
|
|
5
|
+
import { checkboxVariants, resolveCheckboxLabelColor, resolveCheckboxLabelSize } from "./checkbox.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The checkbox's text, inside the checkbox's own tap target.
|
|
9
|
+
*
|
|
10
|
+
* That is the whole reason this part exists next to `Field.Label`, which does
|
|
11
|
+
* the same job a row away: this one sits inside the pressable, so tapping the
|
|
12
|
+
* words toggles the box. Use `Field.Label` for a checkbox that sits beside its
|
|
13
|
+
* name in a horizontal `Field`, and this one for a checkbox that carries its
|
|
14
|
+
* own.
|
|
15
|
+
*
|
|
16
|
+
* Renders `Text.Label` and passes it a step and a colour, never a class for
|
|
17
|
+
* either — the type scale belongs to the preset, and restating it here would be
|
|
18
|
+
* a second definition of `Text.Label` that could drift from it. The `className`
|
|
19
|
+
* carries layout alone. It turns destructive with the box it names, so the pair reads
|
|
20
|
+
* as one state.
|
|
21
|
+
*/
|
|
22
|
+
export function CheckboxLabel({ className, color, size, ...props }: CheckboxLabelProps): ReactElement {
|
|
23
|
+
const { alignment, isInvalid, size: checkboxSize } = useCheckboxPart("Checkbox.Label");
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Text.Label
|
|
27
|
+
className={checkboxVariants({ alignment }).label({ className })}
|
|
28
|
+
color={color ?? resolveCheckboxLabelColor(isInvalid)}
|
|
29
|
+
size={size ?? resolveCheckboxLabelSize(checkboxSize)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
CheckboxLabel.displayName = "DelacourUI.Checkbox.Label";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { CheckboxAlignment, CheckboxColor, CheckboxSize } from "./checkbox.variants";
|
|
3
|
+
|
|
4
|
+
export type CheckboxContextValue = {
|
|
5
|
+
/** What a ticked box means. */
|
|
6
|
+
color: CheckboxColor;
|
|
7
|
+
/** Size of the box, its glyph and the label beside it. */
|
|
8
|
+
size: CheckboxSize;
|
|
9
|
+
/** Which side of the label the box sits on. */
|
|
10
|
+
alignment: CheckboxAlignment;
|
|
11
|
+
/** Whether the box is ticked. */
|
|
12
|
+
isChecked: boolean;
|
|
13
|
+
/** Whether the box reports a mixed state rather than a settled one. */
|
|
14
|
+
isIndeterminate: boolean;
|
|
15
|
+
/** Whether the box reports an invalid value. */
|
|
16
|
+
isInvalid: boolean;
|
|
17
|
+
/** Whether the box is unavailable. */
|
|
18
|
+
isDisabled: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CheckboxGroupContextValue = {
|
|
22
|
+
/** The `value` of every checked child. */
|
|
23
|
+
checked: readonly string[];
|
|
24
|
+
/** Flips one child's value in that list. */
|
|
25
|
+
toggle: (value: string) => void;
|
|
26
|
+
/** Defaults a child takes when it names none of its own. */
|
|
27
|
+
color?: CheckboxColor;
|
|
28
|
+
size?: CheckboxSize;
|
|
29
|
+
alignment?: CheckboxAlignment;
|
|
30
|
+
isInvalid?: boolean;
|
|
31
|
+
isDisabled?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const CheckboxContext = createContext<CheckboxContextValue | null>(null);
|
|
35
|
+
|
|
36
|
+
const CheckboxGroupContext = createContext<CheckboxGroupContextValue | null>(null);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Supplies the enclosing checkbox's axes and state to its subtree.
|
|
40
|
+
*
|
|
41
|
+
* Lives in its own module, importing nothing but React and a type, so a part can
|
|
42
|
+
* read it without importing `./checkbox`. That import would close a cycle, and
|
|
43
|
+
* Metro serves a partially initialised module for a cycle — leaving the context
|
|
44
|
+
* `undefined` at import time and red-boxing the app on a cold start.
|
|
45
|
+
*/
|
|
46
|
+
export function CheckboxProvider({
|
|
47
|
+
value,
|
|
48
|
+
children,
|
|
49
|
+
}: {
|
|
50
|
+
value: CheckboxContextValue;
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}): ReactElement {
|
|
53
|
+
return <CheckboxContext value={value}>{children}</CheckboxContext>;
|
|
54
|
+
}
|
|
55
|
+
CheckboxProvider.displayName = "DelacourUI.Checkbox.Provider";
|
|
56
|
+
|
|
57
|
+
/** The enclosing checkbox's context, or null outside a `<Checkbox>`. */
|
|
58
|
+
export function useCheckboxContext(): CheckboxContextValue | null {
|
|
59
|
+
return use(CheckboxContext);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads the enclosing checkbox's axes and state.
|
|
64
|
+
*
|
|
65
|
+
* Lets a custom child style itself to match without the checkbox having to pass
|
|
66
|
+
* props down through every slot. Throws outside a `<Checkbox>` — use
|
|
67
|
+
* {@link useCheckboxContext} where the enclosing checkbox is optional.
|
|
68
|
+
*/
|
|
69
|
+
export function useCheckbox(): CheckboxContextValue {
|
|
70
|
+
const context = useCheckboxContext();
|
|
71
|
+
if (!context) {
|
|
72
|
+
throw new Error("useCheckbox must be called inside a <Checkbox>.");
|
|
73
|
+
}
|
|
74
|
+
return context;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The enclosing checkbox's context, for a compound part that cannot work without
|
|
79
|
+
* one.
|
|
80
|
+
*
|
|
81
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
82
|
+
* library wants {@link useCheckbox}, whose error message names the hook rather
|
|
83
|
+
* than a part.
|
|
84
|
+
*/
|
|
85
|
+
export function useCheckboxPart(component: string): CheckboxContextValue {
|
|
86
|
+
const context = useCheckboxContext();
|
|
87
|
+
if (!context) {
|
|
88
|
+
throw new Error(`${component} must be rendered inside a <Checkbox>.`);
|
|
89
|
+
}
|
|
90
|
+
return context;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Supplies the enclosing group's checked list and its shared defaults.
|
|
95
|
+
*
|
|
96
|
+
* A second context rather than a field on the first: a checkbox reads its group
|
|
97
|
+
* to work out what it *is*, and publishes its own context describing what it
|
|
98
|
+
* *became*. One value carrying both would have to exist before it could be
|
|
99
|
+
* computed.
|
|
100
|
+
*/
|
|
101
|
+
export function CheckboxGroupProvider({
|
|
102
|
+
value,
|
|
103
|
+
children,
|
|
104
|
+
}: {
|
|
105
|
+
value: CheckboxGroupContextValue;
|
|
106
|
+
children: ReactNode;
|
|
107
|
+
}): ReactElement {
|
|
108
|
+
return <CheckboxGroupContext value={value}>{children}</CheckboxGroupContext>;
|
|
109
|
+
}
|
|
110
|
+
CheckboxGroupProvider.displayName = "DelacourUI.Checkbox.Group.Provider";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The enclosing group's context, or null for a checkbox standing on its own.
|
|
114
|
+
*
|
|
115
|
+
* This is the export the root reads. It is nullable because a checkbox has to
|
|
116
|
+
* work perfectly well alone — a group is a layout and a state owner a caller
|
|
117
|
+
* opts into, not a wrapper anything requires.
|
|
118
|
+
*/
|
|
119
|
+
export function useCheckboxGroupContext(): CheckboxGroupContextValue | null {
|
|
120
|
+
return use(CheckboxGroupContext);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reads the enclosing group's checked list and defaults.
|
|
125
|
+
*
|
|
126
|
+
* For a custom control that has to sit in a group the way a `Checkbox` does.
|
|
127
|
+
* Throws outside one — use {@link useCheckboxGroupContext} where the group is
|
|
128
|
+
* optional, as the checkbox itself does.
|
|
129
|
+
*/
|
|
130
|
+
export function useCheckboxGroup(): CheckboxGroupContextValue {
|
|
131
|
+
const context = useCheckboxGroupContext();
|
|
132
|
+
if (!context) {
|
|
133
|
+
throw new Error("useCheckboxGroup must be called inside a <Checkbox.Group>.");
|
|
134
|
+
}
|
|
135
|
+
return context;
|
|
136
|
+
}
|