@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,811 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BUTTON_RADIUS_TOKENS, ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
|
+
import {
|
|
4
|
+
BUTTON_FOREGROUND_TOKEN,
|
|
5
|
+
BUTTON_GROUP_ORIENTATIONS,
|
|
6
|
+
BUTTON_GROUP_POSITIONS,
|
|
7
|
+
BUTTON_GROUP_SEPARATOR_ORIENTATION,
|
|
8
|
+
BUTTON_ICON_SIZES,
|
|
9
|
+
BUTTON_LABEL_SIZES,
|
|
10
|
+
BUTTON_SIZES,
|
|
11
|
+
BUTTON_SPINNER_PLACEMENTS,
|
|
12
|
+
BUTTON_VARIANTS,
|
|
13
|
+
type ButtonIconSize,
|
|
14
|
+
type ButtonLabelSize,
|
|
15
|
+
buttonVariants,
|
|
16
|
+
resolveButtonFeedback,
|
|
17
|
+
resolveButtonLayout,
|
|
18
|
+
resolveGroupedButtonSize,
|
|
19
|
+
resolveGroupPositions,
|
|
20
|
+
resolveGroupSeams,
|
|
21
|
+
resolveSpinnerSwapIndex,
|
|
22
|
+
} from "./button.variants";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Each square size beside the labelled step it is built from.
|
|
26
|
+
*
|
|
27
|
+
* Written out rather than derived by stripping the prefix, so the test asserts
|
|
28
|
+
* the pairing instead of restating the naming scheme it is meant to check.
|
|
29
|
+
*/
|
|
30
|
+
const SQUARE_PAIRS = [
|
|
31
|
+
["icon-sm", "sm"],
|
|
32
|
+
["icon-md", "md"],
|
|
33
|
+
["icon-lg", "lg"],
|
|
34
|
+
] as const satisfies readonly (readonly [ButtonIconSize, ButtonLabelSize])[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Group shapes the two child walks are swept over together.
|
|
38
|
+
*
|
|
39
|
+
* `true` is a member, `false` a separator. Covers the empty group, a lone
|
|
40
|
+
* member, both ends, a run with middles, and separators leading, trailing,
|
|
41
|
+
* doubled and between — the arrangements a caller actually writes.
|
|
42
|
+
*/
|
|
43
|
+
const SHAPES: readonly boolean[][] = [
|
|
44
|
+
[],
|
|
45
|
+
[true],
|
|
46
|
+
[false],
|
|
47
|
+
[true, true],
|
|
48
|
+
[true, false, true],
|
|
49
|
+
[true, true, true],
|
|
50
|
+
[false, true, false],
|
|
51
|
+
[false, false],
|
|
52
|
+
[true, false, false, true],
|
|
53
|
+
[true, true, false, true, true],
|
|
54
|
+
[false, true, true, false],
|
|
55
|
+
[true, true, true, true, true],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** The `--spacing-button-*` token a root class string sets its height from. */
|
|
59
|
+
function heightToken(cls: string): string | undefined {
|
|
60
|
+
return cls.match(/\bh-(button-[\w-]+)\b/)?.[1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `--spacing-button-*` token a root class string sets its width from. */
|
|
64
|
+
function widthToken(cls: string): string | undefined {
|
|
65
|
+
return cls.match(/\bw-(button-[\w-]+)\b/)?.[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The `--radius-*` token a root class string sets its corners from. */
|
|
69
|
+
function radiusToken(cls: string): string | undefined {
|
|
70
|
+
return cls.match(/\brounded-([\w-]+)\b/)?.[1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Position of a slot's `size-icon-*` token on the shared icon scale.
|
|
75
|
+
*
|
|
76
|
+
* Compares by step rather than by points, so the test says what it means and
|
|
77
|
+
* survives a token being retuned in `tokens.css`. `tokens.test.ts` keeps the
|
|
78
|
+
* array ordered, and asserts the icon actually fits inside the button height.
|
|
79
|
+
*/
|
|
80
|
+
function iconStep(cls: string): number {
|
|
81
|
+
const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
|
|
82
|
+
return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("buttonVariants root slot", () => {
|
|
86
|
+
test("defaults to the primary md variant", () => {
|
|
87
|
+
const cls = buttonVariants().root();
|
|
88
|
+
expect(cls).toContain("bg-primary");
|
|
89
|
+
expect(cls).toContain("h-button-md");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("clips, so a pressed row fades to the edge of its own box", () => {
|
|
93
|
+
expect(buttonVariants().root()).toContain("overflow-hidden");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("gives every variant a distinct root treatment", () => {
|
|
97
|
+
const seen = new Set(BUTTON_VARIANTS.map((variant) => buttonVariants({ variant }).root()));
|
|
98
|
+
expect(seen.size).toBe(BUTTON_VARIANTS.length);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("maps each variant to its surface", () => {
|
|
102
|
+
expect(buttonVariants({ variant: "primary" }).root()).toContain("bg-primary");
|
|
103
|
+
expect(buttonVariants({ variant: "secondary" }).root()).toContain("bg-secondary");
|
|
104
|
+
expect(buttonVariants({ variant: "tertiary" }).root()).toContain("bg-tertiary");
|
|
105
|
+
expect(buttonVariants({ variant: "destructive" }).root()).toContain("bg-destructive");
|
|
106
|
+
expect(buttonVariants({ variant: "destructive-soft" }).root()).toContain("bg-destructive-soft");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("outline draws a border, ghost does not", () => {
|
|
110
|
+
expect(buttonVariants({ variant: "outline" }).root()).toContain("border-border");
|
|
111
|
+
expect(buttonVariants({ variant: "outline" }).root()).toContain("bg-transparent");
|
|
112
|
+
expect(buttonVariants({ variant: "ghost" }).root()).toContain("bg-transparent");
|
|
113
|
+
expect(buttonVariants({ variant: "ghost" }).root()).toContain("border-transparent");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("gives every labelled size a distinct height", () => {
|
|
117
|
+
const seen = new Set(BUTTON_LABEL_SIZES.map((size) => heightToken(buttonVariants({ size }).root())));
|
|
118
|
+
expect(seen.size).toBe(BUTTON_LABEL_SIZES.length);
|
|
119
|
+
expect(buttonVariants({ size: "sm" }).root()).toContain("h-button-sm");
|
|
120
|
+
expect(buttonVariants({ size: "md" }).root()).toContain("h-button-md");
|
|
121
|
+
expect(buttonVariants({ size: "lg" }).root()).toContain("h-button-lg");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an icon size swaps horizontal padding for a square width", () => {
|
|
125
|
+
for (const size of BUTTON_ICON_SIZES) {
|
|
126
|
+
const square = buttonVariants({ size }).root();
|
|
127
|
+
expect(square).toMatch(/\bw-button-(sm|md|lg)\b/);
|
|
128
|
+
expect(square).not.toMatch(/\bpx-\d/);
|
|
129
|
+
}
|
|
130
|
+
for (const size of BUTTON_LABEL_SIZES) {
|
|
131
|
+
const labelled = buttonVariants({ size }).root();
|
|
132
|
+
expect(labelled).toMatch(/\bpx-\d/);
|
|
133
|
+
expect(labelled).not.toMatch(/\bw-button-/);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rounds by default, at the corner token paired with its size", () => {
|
|
138
|
+
expect(radiusToken(buttonVariants().root())).toBe("button-md");
|
|
139
|
+
for (const size of BUTTON_LABEL_SIZES) {
|
|
140
|
+
expect(radiusToken(buttonVariants({ size }).root())).toBe(`button-${size}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("names a corner token the CSS actually declares", () => {
|
|
145
|
+
for (const size of BUTTON_LABEL_SIZES) {
|
|
146
|
+
expect(BUTTON_RADIUS_TOKENS).toContain(`button-${size}`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// The corner belongs to the size axis and nothing else, so no variant, and
|
|
151
|
+
// no state, can leave a second `rounded-*` behind for tailwind-merge to
|
|
152
|
+
// pick between.
|
|
153
|
+
test("carries exactly one corner, whatever else is set", () => {
|
|
154
|
+
for (const variant of BUTTON_VARIANTS) {
|
|
155
|
+
for (const size of BUTTON_SIZES) {
|
|
156
|
+
const cls = buttonVariants({ isDisabled: true, isLoading: true, size, variant }).root();
|
|
157
|
+
expect(cls.match(/\brounded-[\w-]+\b/g)).toHaveLength(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("lets a caller square it off through className", () => {
|
|
163
|
+
expect(radiusToken(buttonVariants().root({ className: "rounded-lg" }))).toBe("lg");
|
|
164
|
+
expect(radiusToken(buttonVariants({ size: "lg" }).root({ className: "rounded-none" }))).toBe("none");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("adds the disabled treatment only when disabled", () => {
|
|
168
|
+
expect(buttonVariants({ isDisabled: true }).root()).toContain("opacity-50");
|
|
169
|
+
expect(buttonVariants({ isDisabled: false }).root()).not.toContain("opacity-50");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("loading does not dim on its own, and dims only when asked", () => {
|
|
173
|
+
expect(buttonVariants({ isLoading: true }).root()).not.toContain("opacity-50");
|
|
174
|
+
expect(buttonVariants({ isDimmedWhileLoading: true }).root()).not.toContain("opacity-50");
|
|
175
|
+
expect(buttonVariants({ isDimmedWhileLoading: true, isLoading: true }).root()).toContain("opacity-50");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("disabled still dims regardless of loading state", () => {
|
|
179
|
+
expect(buttonVariants({ isDisabled: true, isLoading: true }).root()).toContain("opacity-50");
|
|
180
|
+
expect(buttonVariants({ isDimmedWhileLoading: false, isDisabled: true, isLoading: true }).root()).toContain(
|
|
181
|
+
"opacity-50"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("neither loading flag touches the root's text colour rule", () => {
|
|
186
|
+
expect(buttonVariants({ isDimmedWhileLoading: true, isLoading: true }).root()).not.toMatch(/\btext-(?!center\b)/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("merges an incoming className last", () => {
|
|
190
|
+
expect(buttonVariants().root({ className: "bg-info" })).toContain("bg-info");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("resolveButtonLayout", () => {
|
|
195
|
+
test("shows no spinner when not loading, whatever the placement", () => {
|
|
196
|
+
for (const spinnerPlacement of BUTTON_SPINNER_PLACEMENTS) {
|
|
197
|
+
expect(resolveButtonLayout({ spinnerPlacement })).toEqual({ isSpinnerOnly: false, spinnerSide: null });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("places the spinner on the side it was asked for", () => {
|
|
202
|
+
expect(resolveButtonLayout({ isLoading: true, spinnerPlacement: "start" }).spinnerSide).toBe("start");
|
|
203
|
+
expect(resolveButtonLayout({ isLoading: true, spinnerPlacement: "end" }).spinnerSide).toBe("end");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("defaults to a start-placed spinner", () => {
|
|
207
|
+
expect(resolveButtonLayout({ isLoading: true }).spinnerSide).toBe("start");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("only replaces the content and says nothing about the footprint", () => {
|
|
211
|
+
expect(resolveButtonLayout({ isLoading: true, spinnerPlacement: "only" })).toEqual({
|
|
212
|
+
isSpinnerOnly: true,
|
|
213
|
+
spinnerSide: null,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("never shows both a side spinner and replaced content", () => {
|
|
218
|
+
for (const spinnerPlacement of BUTTON_SPINNER_PLACEMENTS) {
|
|
219
|
+
const layout = resolveButtonLayout({ isLoading: true, spinnerPlacement });
|
|
220
|
+
expect(layout.isSpinnerOnly && layout.spinnerSide !== null).toBe(false);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("loading leaves the footprint alone", () => {
|
|
226
|
+
// The bug this guards: a loading button that squared itself would defeat the
|
|
227
|
+
// parent's `alignItems: stretch` and snap a full-width button to a small box
|
|
228
|
+
// flush against the left edge the moment work began.
|
|
229
|
+
test("draws every size exactly as it does when idle", () => {
|
|
230
|
+
for (const size of BUTTON_SIZES) {
|
|
231
|
+
expect(buttonVariants({ isLoading: true, size }).root()).toBe(buttonVariants({ size }).root());
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a labelled button keeps its padding and takes no fixed width", () => {
|
|
236
|
+
for (const size of BUTTON_LABEL_SIZES) {
|
|
237
|
+
const cls = buttonVariants({ isLoading: true, size }).root();
|
|
238
|
+
expect(cls).toMatch(/\bpx-\d/);
|
|
239
|
+
expect(cls).not.toMatch(/\bw-button-/);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("an icon-sized button keeps its square width", () => {
|
|
244
|
+
for (const size of BUTTON_ICON_SIZES) {
|
|
245
|
+
const cls = buttonVariants({ isLoading: true, size }).root();
|
|
246
|
+
expect(cls).toMatch(/\bw-button-(sm|md|lg)\b/);
|
|
247
|
+
expect(cls).not.toMatch(/\bpx-\d/);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// `spinnerPlacement` reaches the content and nothing else — there is no path
|
|
252
|
+
// from it to the root's class string, which is what makes the above hold for
|
|
253
|
+
// `only` as well as for a side-placed spinner.
|
|
254
|
+
test("no placement is visible to the root", () => {
|
|
255
|
+
for (const spinnerPlacement of BUTTON_SPINNER_PLACEMENTS) {
|
|
256
|
+
const layout = resolveButtonLayout({ isLoading: true, spinnerPlacement });
|
|
257
|
+
expect(Object.keys(layout).sort()).toEqual(["isSpinnerOnly", "spinnerSide"]);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("buttonVariants label slot", () => {
|
|
263
|
+
test("carries the text colour, which the root must not", () => {
|
|
264
|
+
for (const variant of BUTTON_VARIANTS) {
|
|
265
|
+
expect(buttonVariants({ variant }).label()).toMatch(/\btext-/);
|
|
266
|
+
expect(buttonVariants({ variant }).root()).not.toMatch(/\btext-(?!center\b)/);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("pairs each surface with its own foreground", () => {
|
|
271
|
+
expect(buttonVariants({ variant: "primary" }).label()).toContain("text-primary-foreground");
|
|
272
|
+
expect(buttonVariants({ variant: "secondary" }).label()).toContain("text-secondary-foreground");
|
|
273
|
+
expect(buttonVariants({ variant: "tertiary" }).label()).toContain("text-tertiary-foreground");
|
|
274
|
+
expect(buttonVariants({ variant: "destructive" }).label()).toContain("text-destructive-foreground");
|
|
275
|
+
expect(buttonVariants({ variant: "destructive-soft" }).label()).toContain("text-destructive-soft-foreground");
|
|
276
|
+
expect(buttonVariants({ variant: "outline" }).label()).toContain("text-foreground");
|
|
277
|
+
expect(buttonVariants({ variant: "ghost" }).label()).toContain("text-foreground");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("scales label text with size", () => {
|
|
281
|
+
expect(buttonVariants({ size: "sm" }).label()).toContain("text-button-sm");
|
|
282
|
+
expect(buttonVariants({ size: "md" }).label()).toContain("text-button-md");
|
|
283
|
+
expect(buttonVariants({ size: "lg" }).label()).toContain("text-button-lg");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// A square holds an icon, but `Button.Label` is still styled outside one —
|
|
287
|
+
// and a caller composing text into an icon button gets the paired step
|
|
288
|
+
// rather than the default.
|
|
289
|
+
test("an icon size carries its step's label treatment", () => {
|
|
290
|
+
for (const [square, step] of SQUARE_PAIRS) {
|
|
291
|
+
expect(buttonVariants({ size: square }).label()).toBe(buttonVariants({ size: step }).label());
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("buttonVariants content slots", () => {
|
|
297
|
+
test("centre their subtree", () => {
|
|
298
|
+
expect(buttonVariants().startContent()).toContain("items-center");
|
|
299
|
+
expect(buttonVariants().endContent()).toContain("items-center");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("do not vary, so a slot outside a Button is still styled", () => {
|
|
303
|
+
for (const size of BUTTON_SIZES) {
|
|
304
|
+
expect(buttonVariants({ size }).startContent()).toBe(buttonVariants().startContent());
|
|
305
|
+
expect(buttonVariants({ size }).endContent()).toBe(buttonVariants().endContent());
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("merge an incoming className", () => {
|
|
310
|
+
expect(buttonVariants().startContent({ className: "gap-1" })).toContain("gap-1");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("resolveSpinnerSwapIndex", () => {
|
|
315
|
+
test("takes the leading icon at the start and the trailing one at the end", () => {
|
|
316
|
+
const icons = [true, false, true];
|
|
317
|
+
expect(resolveSpinnerSwapIndex(icons, "start")).toBe(0);
|
|
318
|
+
expect(resolveSpinnerSwapIndex(icons, "end")).toBe(2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// The bug this guards: a button with one leading icon and spinnerPlacement
|
|
322
|
+
// "end" used to swap that icon, drawing the spinner at the start — the
|
|
323
|
+
// opposite of what the caller asked for.
|
|
324
|
+
test("leaves an icon on the other side alone", () => {
|
|
325
|
+
expect(resolveSpinnerSwapIndex([true, false], "end")).toBeNull();
|
|
326
|
+
expect(resolveSpinnerSwapIndex([false, true], "start")).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// An icon that is not at either edge is not the side's icon either, so the
|
|
330
|
+
// spinner is inserted rather than taking a glyph out of the middle.
|
|
331
|
+
test("ignores an icon boxed in by other children", () => {
|
|
332
|
+
expect(resolveSpinnerSwapIndex([false, true, false], "start")).toBeNull();
|
|
333
|
+
expect(resolveSpinnerSwapIndex([false, true, false], "end")).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// With nothing to replace the spinner is inserted instead, which is what the
|
|
337
|
+
// button did for every case before the swap existed.
|
|
338
|
+
test("reports nothing to swap when no child is an icon", () => {
|
|
339
|
+
expect(resolveSpinnerSwapIndex([false, false], "start")).toBeNull();
|
|
340
|
+
expect(resolveSpinnerSwapIndex([], "end")).toBeNull();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("takes a lone icon for either side when it is the only child", () => {
|
|
344
|
+
expect(resolveSpinnerSwapIndex([true], "start")).toBe(0);
|
|
345
|
+
expect(resolveSpinnerSwapIndex([true], "end")).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("buttonVariants icon slot", () => {
|
|
350
|
+
test("gives every labelled size a distinct icon token, increasing with it", () => {
|
|
351
|
+
const steps = BUTTON_LABEL_SIZES.map((size) => iconStep(buttonVariants({ size }).icon()));
|
|
352
|
+
expect(steps).not.toContain(-1);
|
|
353
|
+
expect(new Set(steps).size).toBe(BUTTON_LABEL_SIZES.length);
|
|
354
|
+
expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// The button indexes the shared icon scale at its own step name, which is
|
|
358
|
+
// what makes a composed Icon and the Spinner that replaces it the same box.
|
|
359
|
+
test("names the icon token matching the button's own size", () => {
|
|
360
|
+
for (const size of BUTTON_LABEL_SIZES) {
|
|
361
|
+
expect(buttonVariants({ size }).icon()).toBe(`size-icon-${size}`);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// An icon takes a colour value rather than a class — the slot sizes it only.
|
|
366
|
+
test("carries no colour", () => {
|
|
367
|
+
for (const size of BUTTON_SIZES) {
|
|
368
|
+
expect(buttonVariants({ size }).icon()).not.toMatch(/\b(bg|text|border)-/);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("BUTTON_FOREGROUND_TOKEN", () => {
|
|
374
|
+
test("names a foreground for every variant", () => {
|
|
375
|
+
// Load-bearing for the Spinner's colour as well as the Icon's: a variant
|
|
376
|
+
// missing here would leave a composed spinner untinted.
|
|
377
|
+
for (const variant of BUTTON_VARIANTS) {
|
|
378
|
+
expect(typeof BUTTON_FOREGROUND_TOKEN[variant]).toBe("string");
|
|
379
|
+
expect(BUTTON_FOREGROUND_TOKEN[variant].length).toBeGreaterThan(0);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("a square size and the step it is built from", () => {
|
|
385
|
+
test("is as wide as it is tall at every size", () => {
|
|
386
|
+
// Both axes name the same button token, so the square cannot drift the
|
|
387
|
+
// way two hand-picked numbers could.
|
|
388
|
+
for (const [square] of SQUARE_PAIRS) {
|
|
389
|
+
const cls = buttonVariants({ size: square }).root();
|
|
390
|
+
const token = heightToken(cls);
|
|
391
|
+
expect(token).toBeDefined();
|
|
392
|
+
expect(widthToken(cls)).toBe(token);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// The pairing, not the points: a square names its step's height, corner and
|
|
397
|
+
// icon token, so retuning a token in `tokens.css` moves both together and a
|
|
398
|
+
// composed Icon stays the same box as the Spinner that replaces it.
|
|
399
|
+
test("names its step's height, corner and icon token", () => {
|
|
400
|
+
for (const [square, step] of SQUARE_PAIRS) {
|
|
401
|
+
const squareRoot = buttonVariants({ size: square }).root();
|
|
402
|
+
const stepRoot = buttonVariants({ size: step }).root();
|
|
403
|
+
expect(heightToken(squareRoot)).toBe(heightToken(stepRoot));
|
|
404
|
+
expect(radiusToken(squareRoot)).toBe(radiusToken(stepRoot));
|
|
405
|
+
expect(buttonVariants({ size: square }).icon()).toBe(buttonVariants({ size: step }).icon());
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("covers every icon size, and the two families do not overlap", () => {
|
|
410
|
+
expect(SQUARE_PAIRS.map(([square]) => square)).toEqual([...BUTTON_ICON_SIZES]);
|
|
411
|
+
expect(new Set<string>(BUTTON_SIZES).size).toBe(BUTTON_SIZES.length);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** Every `rounded-*` class a root string carries, in emission order. */
|
|
416
|
+
function radiusClasses(cls: string): string[] {
|
|
417
|
+
return cls.match(/\brounded-[\w-]+\b/g) ?? [];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
describe("a joined member's corner", () => {
|
|
421
|
+
test("replaces the size corner rather than layering a squaring class on it", () => {
|
|
422
|
+
// The layered form would work at runtime — Uniwind arbitrates a className
|
|
423
|
+
// string per style property in token order, and React Native puts a
|
|
424
|
+
// per-corner radius above the uniform one. It is tailwind-merge that makes
|
|
425
|
+
// it a trap: the all-corner group annihilates every side group emitted
|
|
426
|
+
// before it, so a `rounded-button-*` surviving on the size axis would
|
|
427
|
+
// delete the squaring pair the moment `tv` emitted it second.
|
|
428
|
+
for (const size of BUTTON_SIZES) {
|
|
429
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
430
|
+
for (const groupPosition of ["first", "middle", "last"] as const) {
|
|
431
|
+
const cls = buttonVariants({ groupPosition, orientation, size }).root();
|
|
432
|
+
expect(cls).not.toMatch(/\brounded-button-/);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("draws the same corner alone as it does as a group of one", () => {
|
|
439
|
+
// A group that renders one button conditionally must not look different
|
|
440
|
+
// from the button on its own.
|
|
441
|
+
for (const size of BUTTON_SIZES) {
|
|
442
|
+
const step = size.replace(/^icon-/, "");
|
|
443
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
444
|
+
expect(radiusClasses(buttonVariants({ groupPosition: "only", orientation, size }).root())).toEqual([
|
|
445
|
+
`rounded-button-${step}`,
|
|
446
|
+
]);
|
|
447
|
+
expect(radiusClasses(buttonVariants({ groupPosition: "none", orientation, size }).root())).toEqual([
|
|
448
|
+
`rounded-button-${step}`,
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("squares the pair of corners crossing the seam, at every position", () => {
|
|
455
|
+
// Keyed on the step rather than the size, because a square size draws the
|
|
456
|
+
// same corner as the labelled step it is built from — `icon-md` is `md`'s
|
|
457
|
+
// height, and a corner is half a height.
|
|
458
|
+
const expected: Record<string, Record<string, (step: string) => string[]>> = {
|
|
459
|
+
horizontal: {
|
|
460
|
+
first: (step) => [`rounded-s-button-${step}`, "rounded-e-none"],
|
|
461
|
+
last: (step) => [`rounded-e-button-${step}`, "rounded-s-none"],
|
|
462
|
+
middle: () => ["rounded-none"],
|
|
463
|
+
},
|
|
464
|
+
vertical: {
|
|
465
|
+
first: (step) => [`rounded-t-button-${step}`, "rounded-b-none"],
|
|
466
|
+
last: (step) => [`rounded-b-button-${step}`, "rounded-t-none"],
|
|
467
|
+
middle: () => ["rounded-none"],
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
472
|
+
for (const groupPosition of ["first", "middle", "last"] as const) {
|
|
473
|
+
for (const size of BUTTON_SIZES) {
|
|
474
|
+
const step = size.replace(/^icon-/, "");
|
|
475
|
+
expect(radiusClasses(buttonVariants({ groupPosition, orientation, size }).root())).toEqual(
|
|
476
|
+
expected[orientation][groupPosition](step)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("draws the same corner at a square size as at the step it is built from", () => {
|
|
484
|
+
// The pairing the compound cells rest on: a square button is the labelled
|
|
485
|
+
// step with its padding traded for a width, so its corner cannot differ.
|
|
486
|
+
for (const [square, label] of SQUARE_PAIRS) {
|
|
487
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
488
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
489
|
+
expect(radiusClasses(buttonVariants({ groupPosition, orientation, size: square }).root())).toEqual(
|
|
490
|
+
radiusClasses(buttonVariants({ groupPosition, orientation, size: label }).root())
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("never mixes a physical corner with a logical one", () => {
|
|
498
|
+
// React Native resolves a physical corner above a logical one, so a stray
|
|
499
|
+
// `rounded-t-*` on a horizontal member would silently outrank the
|
|
500
|
+
// `rounded-s-*` beside it and round the wrong edge.
|
|
501
|
+
for (const size of BUTTON_SIZES) {
|
|
502
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
503
|
+
const horizontal = buttonVariants({ groupPosition, orientation: "horizontal", size }).root();
|
|
504
|
+
expect(horizontal).not.toMatch(/\brounded-[trbl]-/);
|
|
505
|
+
|
|
506
|
+
const vertical = buttonVariants({ groupPosition, orientation: "vertical", size }).root();
|
|
507
|
+
expect(vertical).not.toMatch(/\brounded-[se]-/);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("names corner tokens the CSS actually declares", () => {
|
|
513
|
+
const named = new Set<string>();
|
|
514
|
+
for (const size of BUTTON_SIZES) {
|
|
515
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
516
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
517
|
+
for (const cls of radiusClasses(buttonVariants({ groupPosition, orientation, size }).root())) {
|
|
518
|
+
named.add(cls.replace(/^rounded-(?:[sebtrl]-)?/, ""));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const tokens = [...named].filter((token) => token !== "none");
|
|
525
|
+
expect(tokens.length).toBeGreaterThan(0);
|
|
526
|
+
expect(tokens.filter((token) => !(BUTTON_RADIUS_TOKENS as readonly string[]).includes(token))).toEqual([]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("still lets a caller square it off through className", () => {
|
|
530
|
+
// The property the whole replacement design exists to keep: an all-corner
|
|
531
|
+
// class from the caller beats the pair, at every position, in one class.
|
|
532
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
533
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
534
|
+
const cls = buttonVariants({ groupPosition, orientation }).root({ className: "rounded-lg" });
|
|
535
|
+
expect(radiusClasses(cls)).toEqual(["rounded-lg"]);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe("a joined member's seam", () => {
|
|
542
|
+
test("overlaps the member before it, on the axis the group runs along", () => {
|
|
543
|
+
expect(buttonVariants({ isSeamed: true, orientation: "horizontal" }).root()).toContain("-ms-px");
|
|
544
|
+
expect(buttonVariants({ isSeamed: true, orientation: "vertical" }).root()).toContain("-mt-px");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("overlaps on one axis only", () => {
|
|
548
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
549
|
+
const cls = buttonVariants({ isSeamed: true, orientation }).root();
|
|
550
|
+
expect(cls.match(/\B-(?:ms|mt)-px\b/g)).toHaveLength(1);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("does not overlap when it is not seamed", () => {
|
|
555
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
556
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
557
|
+
const cls = buttonVariants({ groupPosition, isSeamed: false, orientation }).root();
|
|
558
|
+
expect(cls).not.toMatch(/\B-(?:ms|mt)-px\b/);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("never drops a border to close the seam", () => {
|
|
564
|
+
// The base reserves `border border-transparent` so switching variant never
|
|
565
|
+
// resizes the box. Dropping a point of border would make a member narrower
|
|
566
|
+
// than its neighbours and shift its centred content half a point.
|
|
567
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
568
|
+
for (const groupPosition of BUTTON_GROUP_POSITIONS) {
|
|
569
|
+
const cls = buttonVariants({ groupPosition, isSeamed: true, orientation }).root();
|
|
570
|
+
expect(cls).not.toMatch(/\bborder-(?:[sebtrl]-)?0\b/);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("buttonVariants group slot", () => {
|
|
577
|
+
test("runs along the axis it is given", () => {
|
|
578
|
+
expect(buttonVariants({ orientation: "horizontal" }).group()).toContain("flex-row");
|
|
579
|
+
expect(buttonVariants({ orientation: "vertical" }).group()).toContain("flex-col");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("runs along one axis only", () => {
|
|
583
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
584
|
+
const cls = buttonVariants({ orientation }).group();
|
|
585
|
+
expect(cls.match(/\bflex-(?:row|col)\b/g)).toHaveLength(1);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("holds no gap", () => {
|
|
590
|
+
// A gap is the seam this component exists to close.
|
|
591
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
592
|
+
expect(buttonVariants({ orientation }).group()).not.toMatch(/\bgap-/);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("does not clip", () => {
|
|
597
|
+
// A clip would square off the very corners the position compounds just
|
|
598
|
+
// rounded, and would have to restate the group's own corner to avoid it.
|
|
599
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
600
|
+
expect(buttonVariants({ orientation }).group()).not.toContain("overflow-hidden");
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("paints no disabled state of its own", () => {
|
|
605
|
+
// A disabled group publishes `isDisabled` and each member fades itself. A
|
|
606
|
+
// group fading too would compound down to a quarter opacity.
|
|
607
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
608
|
+
expect(buttonVariants({ isDisabled: true, orientation }).group()).not.toMatch(/\bopacity-/);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("keeps a separator from being squeezed away by flexible members", () => {
|
|
613
|
+
expect(buttonVariants().groupSeparator()).toContain("shrink-0");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("leaves a separator's thickness to the separator", () => {
|
|
617
|
+
// The line is a `Separator`, which owns its own hairline on its own axis.
|
|
618
|
+
expect(buttonVariants().groupSeparator()).not.toMatch(/\b[wh]-px\b/);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe("BUTTON_GROUP_SEPARATOR_ORIENTATION", () => {
|
|
623
|
+
test("crosses the axis the group runs along", () => {
|
|
624
|
+
// A rule drawn along the run would be invisible — it would sit under the
|
|
625
|
+
// members rather than between them.
|
|
626
|
+
expect(BUTTON_GROUP_SEPARATOR_ORIENTATION.horizontal).toBe("vertical");
|
|
627
|
+
expect(BUTTON_GROUP_SEPARATOR_ORIENTATION.vertical).toBe("horizontal");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("names an axis the group itself knows", () => {
|
|
631
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
632
|
+
expect(BUTTON_GROUP_ORIENTATIONS).toContain(BUTTON_GROUP_SEPARATOR_ORIENTATION[orientation]);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
describe("resolveGroupPositions", () => {
|
|
638
|
+
test("holds no position for an empty group", () => {
|
|
639
|
+
expect(resolveGroupPositions([])).toEqual([]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("calls a lone member `only`", () => {
|
|
643
|
+
expect(resolveGroupPositions([true])).toEqual(["only"]);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("gives a pair the two ends and no middle", () => {
|
|
647
|
+
expect(resolveGroupPositions([true, true])).toEqual(["first", "last"]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("fills everything between the ends with middles", () => {
|
|
651
|
+
expect(resolveGroupPositions([true, true, true])).toEqual(["first", "middle", "last"]);
|
|
652
|
+
expect(resolveGroupPositions([true, true, true, true, true])).toEqual([
|
|
653
|
+
"first",
|
|
654
|
+
"middle",
|
|
655
|
+
"middle",
|
|
656
|
+
"middle",
|
|
657
|
+
"last",
|
|
658
|
+
]);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("does not let a separator consume a position", () => {
|
|
662
|
+
// A separator is a rule, not a segment, so the buttons either side of one
|
|
663
|
+
// are still the first and the last and keep their rounded outer corners.
|
|
664
|
+
expect(resolveGroupPositions([true, false, true])).toEqual(["first", null, "last"]);
|
|
665
|
+
expect(resolveGroupPositions([true, false, false, true])).toEqual(["first", null, null, "last"]);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("ignores a separator at either edge", () => {
|
|
669
|
+
expect(resolveGroupPositions([false, true, false])).toEqual([null, "only", null]);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("holds no position for a group of separators", () => {
|
|
673
|
+
expect(resolveGroupPositions([false, false])).toEqual([null, null]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("places exactly as many members as it was given", () => {
|
|
677
|
+
for (const shape of SHAPES) {
|
|
678
|
+
const placed = resolveGroupPositions(shape).filter((position) => position !== null);
|
|
679
|
+
expect(placed).toHaveLength(shape.filter(Boolean).length);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("never names both an `only` and an end", () => {
|
|
684
|
+
// The invariant a rounded group rests on: either one member draws both
|
|
685
|
+
// outer corners, or two members draw one each.
|
|
686
|
+
for (const shape of SHAPES) {
|
|
687
|
+
const placed = resolveGroupPositions(shape);
|
|
688
|
+
const only = placed.filter((position) => position === "only").length;
|
|
689
|
+
const first = placed.filter((position) => position === "first").length;
|
|
690
|
+
const last = placed.filter((position) => position === "last").length;
|
|
691
|
+
|
|
692
|
+
expect(only).toBeLessThanOrEqual(1);
|
|
693
|
+
expect(first).toBe(last);
|
|
694
|
+
expect(first).toBeLessThanOrEqual(1);
|
|
695
|
+
if (only === 1) expect(first).toBe(0);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("resolveGroupSeams", () => {
|
|
701
|
+
test("finds no seam in an empty group", () => {
|
|
702
|
+
expect(resolveGroupSeams([])).toEqual([]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("never overlaps the first member", () => {
|
|
706
|
+
// There is nothing behind it to overlap, and pulling it back a point would
|
|
707
|
+
// misalign the group with everything above it.
|
|
708
|
+
expect(resolveGroupSeams([true])).toEqual([false]);
|
|
709
|
+
expect(resolveGroupSeams([true, true, true])).toEqual([false, true, true]);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("does not overlap a member that follows a separator", () => {
|
|
713
|
+
// A one-point rule under a one-point overlap is an invisible rule.
|
|
714
|
+
expect(resolveGroupSeams([true, false, true])).toEqual([false, false, false]);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("never overlaps a separator itself", () => {
|
|
718
|
+
expect(resolveGroupSeams([true, false, true])[1]).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("agrees with resolveGroupPositions about who is a member", () => {
|
|
722
|
+
for (const shape of SHAPES) {
|
|
723
|
+
const positions = resolveGroupPositions(shape);
|
|
724
|
+
const seams = resolveGroupSeams(shape);
|
|
725
|
+
|
|
726
|
+
for (const [index, seamed] of seams.entries()) {
|
|
727
|
+
if (seamed) expect(positions[index]).not.toBeNull();
|
|
728
|
+
// An end that draws a rounded outer corner is never pulled back.
|
|
729
|
+
if (positions[index] === "first" || positions[index] === "only") expect(seamed).toBe(false);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("resolveButtonFeedback", () => {
|
|
736
|
+
test("presses with a scale when it stands alone", () => {
|
|
737
|
+
expect(resolveButtonFeedback(undefined, undefined, false)).toBe("scale");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("presses with a fade when it is joined", () => {
|
|
741
|
+
// A scaling member pulls its own edges in while its neighbours hold still,
|
|
742
|
+
// so the seam the group exists to close tears open for the length of the
|
|
743
|
+
// press. A fade moves no geometry.
|
|
744
|
+
expect(resolveButtonFeedback(undefined, undefined, true)).toBe("fade");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("lets the group name a treatment for all of its members", () => {
|
|
748
|
+
expect(resolveButtonFeedback(undefined, "scale", true)).toBe("scale");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("lets a member outrank its group", () => {
|
|
752
|
+
expect(resolveButtonFeedback("scale-fade", "scale", true)).toBe("scale-fade");
|
|
753
|
+
expect(resolveButtonFeedback("scale", undefined, true)).toBe("scale");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("honours an explicit `none` rather than reading it as unset", () => {
|
|
757
|
+
// `none` is a value, not an absence — a `||` ladder here would swallow it.
|
|
758
|
+
expect(resolveButtonFeedback("none", "scale", true)).toBe("none");
|
|
759
|
+
expect(resolveButtonFeedback(undefined, "none", true)).toBe("none");
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("resolveGroupedButtonSize", () => {
|
|
764
|
+
test("keeps its own size when it stands alone", () => {
|
|
765
|
+
for (const size of BUTTON_SIZES) {
|
|
766
|
+
expect(resolveGroupedButtonSize(size, undefined)).toBe(size);
|
|
767
|
+
}
|
|
768
|
+
expect(resolveGroupedButtonSize(undefined, undefined)).toBe("md");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("takes the group's step, so members line up", () => {
|
|
772
|
+
// Controls of different heights do not join, so the step is the group's
|
|
773
|
+
// to give and a member cannot argue with it — whichever shape either side
|
|
774
|
+
// happens to name.
|
|
775
|
+
for (const [square, label] of SQUARE_PAIRS) {
|
|
776
|
+
expect(resolveGroupedButtonSize("lg", label)).toBe(label);
|
|
777
|
+
expect(resolveGroupedButtonSize("icon-lg", label)).toBe(square);
|
|
778
|
+
expect(resolveGroupedButtonSize("lg", square)).toBe(label);
|
|
779
|
+
expect(resolveGroupedButtonSize("icon-lg", square)).toBe(square);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("keeps its own shape, so a square member stays square", () => {
|
|
784
|
+
// The shape is the member's to keep. Resolving the whole prop to the
|
|
785
|
+
// group's value would make a square member impossible inside a run — the
|
|
786
|
+
// icon button ending a split button would grow a label's padding and lose
|
|
787
|
+
// its width.
|
|
788
|
+
for (const [square, label] of SQUARE_PAIRS) {
|
|
789
|
+
expect(resolveGroupedButtonSize(square, label)).toBe(square);
|
|
790
|
+
expect(resolveGroupedButtonSize(label, square)).toBe(label);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("crosses a member's shape with the group's step", () => {
|
|
795
|
+
expect(resolveGroupedButtonSize("icon-lg", "sm")).toBe("icon-sm");
|
|
796
|
+
expect(resolveGroupedButtonSize("lg", "icon-sm")).toBe("sm");
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("inherits the group's shape when the member names no size", () => {
|
|
800
|
+
expect(resolveGroupedButtonSize(undefined, "icon-lg")).toBe("icon-lg");
|
|
801
|
+
expect(resolveGroupedButtonSize(undefined, "sm")).toBe("sm");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("always names a size the variants actually declare", () => {
|
|
805
|
+
for (const own of [...BUTTON_SIZES, undefined]) {
|
|
806
|
+
for (const group of [...BUTTON_SIZES, undefined]) {
|
|
807
|
+
expect(BUTTON_SIZES as readonly string[]).toContain(resolveGroupedButtonSize(own, group));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
});
|