@delacour/react-native-ui 0.4.0 → 0.4.1-alpha.20261021071515
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/package.json +1 -1
- package/src/components/badge/AGENTS.md +3 -1
- package/src/components/badge/index.ts +2 -0
- package/src/components/button/AGENTS.md +2 -1
- package/src/components/button/index.ts +2 -0
- package/src/components/calendar/AGENTS.md +12 -7
- package/src/components/calendar/calendar-day.tsx +7 -1
- package/src/components/calendar/calendar.variants.test.ts +37 -16
- package/src/components/calendar/calendar.variants.ts +21 -12
- package/src/components/screen/AGENTS.md +1 -1
- package/src/components/screen/index.ts +2 -0
- package/src/components/surface/AGENTS.md +1 -0
- package/src/components/surface/index.ts +2 -0
- package/src/components/text-animation/AGENTS.md +1 -1
- package/src/components/text-animation/index.ts +4 -0
- package/src/components/text-animation/text-animation.variants.test.ts +9 -0
- package/src/components/text-animation/text-animation.variants.ts +3 -1
- package/src/variant-axes.test.ts +87 -0
package/package.json
CHANGED
|
@@ -90,4 +90,6 @@ A compact label for status, category or count. Compound root plus `Badge.Label`,
|
|
|
90
90
|
- **`material="etched"` squares the capsule and tints.** The root and the close button take
|
|
91
91
|
`rounded-sm`; `soft` status fills become `bg-<status>/8` in light and `/16` in dark, written out
|
|
92
92
|
per colour because Tailwind's scanner cannot see a built class. The material rides the badge
|
|
93
|
-
context so the close button follows the root.
|
|
93
|
+
context so the close button follows the root. `BADGE_MATERIALS` and `BadgeMaterial` are exported
|
|
94
|
+
from the index beside the other three axes — a public prop whose type a caller cannot name is half
|
|
95
|
+
an API.
|
|
@@ -4,9 +4,11 @@ export type { BadgeSlotProps } from "./badge.types";
|
|
|
4
4
|
export {
|
|
5
5
|
BADGE_COLORS,
|
|
6
6
|
BADGE_FOREGROUND_TOKEN,
|
|
7
|
+
BADGE_MATERIALS,
|
|
7
8
|
BADGE_SIZES,
|
|
8
9
|
BADGE_VARIANTS,
|
|
9
10
|
type BadgeColor,
|
|
11
|
+
type BadgeMaterial,
|
|
10
12
|
type BadgeSize,
|
|
11
13
|
type BadgeVariant,
|
|
12
14
|
type BadgeVariantProps,
|
|
@@ -254,4 +254,5 @@ for a chunk that says something rather than doing something. An
|
|
|
254
254
|
inset top edge at 16%); `secondary`, `tertiary` and `outline` take the light/dark edge
|
|
255
255
|
(`shadow-etched dark:shadow-etched-dark`); `ghost`, `destructive` and `destructive-soft` stay flat
|
|
256
256
|
— a ghost has no surface to lift and a signal colour does not take a second highlight. The press
|
|
257
|
-
scale is already 0.97. The tokens live in `tokens.css`.
|
|
257
|
+
scale is already 0.97. The tokens live in `tokens.css`. `BUTTON_MATERIALS` and `ButtonMaterial`
|
|
258
|
+
are exported from the index like every other axis.
|
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
BUTTON_GROUP_SEPARATOR_ORIENTATION,
|
|
24
24
|
BUTTON_ICON_SIZES,
|
|
25
25
|
BUTTON_LABEL_SIZES,
|
|
26
|
+
BUTTON_MATERIALS,
|
|
26
27
|
BUTTON_SIZES,
|
|
27
28
|
BUTTON_SPINNER_PLACEMENTS,
|
|
28
29
|
BUTTON_VARIANTS,
|
|
@@ -33,6 +34,7 @@ export {
|
|
|
33
34
|
type ButtonIconSize,
|
|
34
35
|
type ButtonLabelSize,
|
|
35
36
|
type ButtonLayout,
|
|
37
|
+
type ButtonMaterial,
|
|
36
38
|
type ButtonSize,
|
|
37
39
|
type ButtonSpinnerPlacement,
|
|
38
40
|
type ButtonVariant,
|
|
@@ -75,13 +75,18 @@ sheets.
|
|
|
75
75
|
|
|
76
76
|
## Drawing
|
|
77
77
|
|
|
78
|
-
- **A day is three layers:**
|
|
79
|
-
the label. The band spans the full cell so adjacent days join into one strip.
|
|
80
|
-
- **
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
- **A day is three layers:** the band (`bandTrack`, absolute, edge to edge, behind), `dayBase`
|
|
79
|
+
(the circle) and the label. The band spans the full cell so adjacent days join into one strip.
|
|
80
|
+
- **An end's band runs under the whole circle, capped at the circle's radius.** `bandTrack` clips
|
|
81
|
+
to the cell and centres `bandAnchor`, a box `w-input-*` wide — the width `size-input-*` gives the
|
|
82
|
+
circle. The band hangs off the anchor: start from its left edge (`rounded-l-full`) out past the
|
|
83
|
+
cell's right, end the mirror, middle past both edges and square; the clip trims each to the
|
|
84
|
+
cell. Three earlier versions failed: a band rounded across the whole end cell showed a crescent
|
|
85
|
+
beside the circle; one stopped at the cell's centre put half a translucent `secondary` circle
|
|
86
|
+
over band and half over the page — a two-tone circle; and one sized by `basis-input-*` did not
|
|
87
|
+
follow the house style's runtime retune of the input scale, so at `lg` the cap slid ~6pt off
|
|
88
|
+
the circle. A lone `only` day draws no band. At a week's edge the strip simply stops and
|
|
89
|
+
resumes on the next row.
|
|
85
90
|
- **Today is a ring (`border-ring`) and `text-primary`, never a fill**, so it cannot be mistaken
|
|
86
91
|
for a selection.
|
|
87
92
|
- **Variant:** `primary` fills `bg-primary`, band `bg-primary/15`; `secondary` fills
|
|
@@ -95,7 +95,13 @@ export function CalendarDay({ date, children, className }: CalendarDayProps): Re
|
|
|
95
95
|
onPress={handlePress}
|
|
96
96
|
testID={resolvePartTestID(calendar.testID, `day-${serialiseDate(date)}`)}
|
|
97
97
|
>
|
|
98
|
-
{showsBand ?
|
|
98
|
+
{showsBand ? (
|
|
99
|
+
<View className={slots.bandTrack()}>
|
|
100
|
+
<View className={slots.bandAnchor()}>
|
|
101
|
+
<View className={slots.band()} />
|
|
102
|
+
</View>
|
|
103
|
+
</View>
|
|
104
|
+
) : null}
|
|
99
105
|
<View className={slots.dayBase()}>{content}</View>
|
|
100
106
|
</Pressable>
|
|
101
107
|
);
|
|
@@ -90,6 +90,8 @@ describe("text colour stays on the text", () => {
|
|
|
90
90
|
"week",
|
|
91
91
|
"cell",
|
|
92
92
|
"dayBase",
|
|
93
|
+
"bandTrack",
|
|
94
|
+
"bandAnchor",
|
|
93
95
|
"band",
|
|
94
96
|
"pickerGrid",
|
|
95
97
|
"pickerItem",
|
|
@@ -111,33 +113,52 @@ describe("text colour stays on the text", () => {
|
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
describe("range band geometry", () => {
|
|
114
|
-
const
|
|
116
|
+
const slotsOf = (rangeRole: CalendarRangeRole) => calendarVariants({ rangeRole });
|
|
117
|
+
const bandOf = (rangeRole: CalendarRangeRole) => slotsOf(rangeRole).band();
|
|
115
118
|
|
|
116
119
|
// The cell is wider than the circle, so a band end rounded across the whole cell pokes out
|
|
117
|
-
// beside the circle. Each end instead
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
// beside the circle. Each end instead runs under the whole circle with a cap of its radius, so a
|
|
121
|
+
// translucent circle lies over band everywhere and never shows a two-tone half.
|
|
122
|
+
test("the track clips to the cell and centres the anchor", () => {
|
|
123
|
+
const track = slotsOf("start").bandTrack();
|
|
124
|
+
expect(track).toMatch(/\babsolute\b/);
|
|
125
|
+
expect(track).toMatch(/\binset-0\b/);
|
|
126
|
+
expect(track).toMatch(/\bitems-center\b/);
|
|
127
|
+
expect(track).toMatch(/\boverflow-hidden\b/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// `w-input-*` is the width `size-input-*` sets, so the anchor tracks the circle through a
|
|
131
|
+
// runtime retune of the input scale; `basis-input-*` did not, and the cap slid off the circle.
|
|
132
|
+
test("the anchor is exactly as wide as the circle", () => {
|
|
133
|
+
for (const size of ["sm", "md", "lg"] as const) {
|
|
134
|
+
const slots = calendarVariants({ rangeRole: "start", size });
|
|
135
|
+
expect(slots.bandAnchor()).toContain(`w-input-${size}`);
|
|
136
|
+
expect(slots.dayBase()).toContain(`size-input-${size}`);
|
|
137
|
+
}
|
|
121
138
|
});
|
|
122
139
|
|
|
123
|
-
test("
|
|
124
|
-
expect(bandOf("
|
|
125
|
-
expect(bandOf("
|
|
140
|
+
test("start runs from the circle's left edge out past the cell, capped on the left", () => {
|
|
141
|
+
expect(bandOf("start")).toMatch(/(?:^|\s)left-0\b/);
|
|
142
|
+
expect(bandOf("start")).toMatch(/-right-\[\d+px\]/);
|
|
143
|
+
expect(bandOf("start")).toMatch(/\brounded-l-full\b/);
|
|
144
|
+
expect(bandOf("start")).not.toMatch(/\brounded-r-full\b/);
|
|
126
145
|
});
|
|
127
146
|
|
|
128
|
-
test("
|
|
129
|
-
expect(bandOf("
|
|
130
|
-
expect(bandOf("
|
|
147
|
+
test("end mirrors start", () => {
|
|
148
|
+
expect(bandOf("end")).toMatch(/(?:^|\s)right-0\b/);
|
|
149
|
+
expect(bandOf("end")).toMatch(/-left-\[\d+px\]/);
|
|
150
|
+
expect(bandOf("end")).toMatch(/\brounded-r-full\b/);
|
|
151
|
+
expect(bandOf("end")).not.toMatch(/\brounded-l-full\b/);
|
|
131
152
|
});
|
|
132
153
|
|
|
133
|
-
test("
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
test("middle runs past both edges, square", () => {
|
|
155
|
+
expect(bandOf("middle")).toMatch(/-left-\[\d+px\]/);
|
|
156
|
+
expect(bandOf("middle")).toMatch(/-right-\[\d+px\]/);
|
|
157
|
+
expect(bandOf("middle")).not.toMatch(/rounded-(?:l-|r-)?full/);
|
|
137
158
|
});
|
|
138
159
|
|
|
139
160
|
test("a lone day draws no band", () => {
|
|
140
|
-
expect(
|
|
161
|
+
expect(slotsOf("only").bandTrack()).toContain("hidden");
|
|
141
162
|
});
|
|
142
163
|
|
|
143
164
|
test("a selected day is a circle, never square", () => {
|
|
@@ -54,12 +54,14 @@ export const CALENDAR_PAGE_VELOCITY = 500;
|
|
|
54
54
|
* whole cell so neighbouring days join into one continuous strip — a gap between columns would
|
|
55
55
|
* break it into beads.
|
|
56
56
|
*
|
|
57
|
-
* **The band ends under the circle, never beside it.**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
57
|
+
* **The band ends under the whole circle, never beside it.** `bandTrack` clips to the cell and
|
|
58
|
+
* centres `bandAnchor`, a box as wide as the circle — `w-input-*`, the width `size-input-*` sets,
|
|
59
|
+
* so a runtime retune of the input scale moves both. The band hangs off the anchor: start from
|
|
60
|
+
* its left edge, capped at the circle's radius, out past the cell's right; end the mirror; every
|
|
61
|
+
* day between past both edges, so the clip leaves it spanning the whole cell square — including at a week's edge, where the strip simply stops and
|
|
62
|
+
* picks up again on the next row. A band rounded across the whole end cell poked a crescent out
|
|
63
|
+
* beside the circle; one stopped at the cell's centre left half a translucent `secondary` circle
|
|
64
|
+
* over band and half over the page, a two-tone circle. A one-day range draws no band at all.
|
|
63
65
|
*
|
|
64
66
|
* **The cell steps on the input scale** — `h-input-*` and `text-input-*` — so a calendar inline
|
|
65
67
|
* beside a field shares its density, and a retune of the field retunes the calendar.
|
|
@@ -91,7 +93,11 @@ export const calendarVariants = tv({
|
|
|
91
93
|
week: "flex-row",
|
|
92
94
|
/** One day's column share, and the height of its touch target. */
|
|
93
95
|
cell: "flex-1 items-center justify-center",
|
|
94
|
-
/** The
|
|
96
|
+
/** The cell-sized clip behind a day inside a range, centring the band's anchor. */
|
|
97
|
+
bandTrack: "absolute inset-0 items-center overflow-hidden",
|
|
98
|
+
/** A circle-wide box where the circle sits, that the band hangs off. */
|
|
99
|
+
bandAnchor: "h-full",
|
|
100
|
+
/** The strip behind a day inside a range, run past the cell and clipped by the track. */
|
|
95
101
|
band: "absolute inset-y-0",
|
|
96
102
|
/** The circle a selection fills and today rings. */
|
|
97
103
|
dayBase: "items-center justify-center rounded-full border border-transparent",
|
|
@@ -113,6 +119,7 @@ export const calendarVariants = tv({
|
|
|
113
119
|
sm: {
|
|
114
120
|
captionText: "text-input-sm",
|
|
115
121
|
cell: "h-input-sm",
|
|
122
|
+
bandAnchor: "w-input-sm",
|
|
116
123
|
dayBase: "size-input-sm",
|
|
117
124
|
dayLabel: "text-input-sm",
|
|
118
125
|
pickerItem: "h-input-sm",
|
|
@@ -121,6 +128,7 @@ export const calendarVariants = tv({
|
|
|
121
128
|
md: {
|
|
122
129
|
captionText: "text-input-md",
|
|
123
130
|
cell: "h-input-md",
|
|
131
|
+
bandAnchor: "w-input-md",
|
|
124
132
|
dayBase: "size-input-md",
|
|
125
133
|
dayLabel: "text-input-md",
|
|
126
134
|
pickerItem: "h-input-md",
|
|
@@ -129,6 +137,7 @@ export const calendarVariants = tv({
|
|
|
129
137
|
lg: {
|
|
130
138
|
captionText: "text-input-lg",
|
|
131
139
|
cell: "h-input-lg",
|
|
140
|
+
bandAnchor: "w-input-lg",
|
|
132
141
|
dayBase: "size-input-lg",
|
|
133
142
|
dayLabel: "text-input-lg",
|
|
134
143
|
pickerItem: "h-input-lg",
|
|
@@ -141,11 +150,11 @@ export const calendarVariants = tv({
|
|
|
141
150
|
band: { dayBase: "bg-transparent" },
|
|
142
151
|
},
|
|
143
152
|
rangeRole: {
|
|
144
|
-
none: {
|
|
145
|
-
start: { band: "left-
|
|
146
|
-
middle: { band: "
|
|
147
|
-
end: { band: "left-
|
|
148
|
-
only: {
|
|
153
|
+
none: { bandTrack: "hidden" },
|
|
154
|
+
start: { band: "-right-[1000px] left-0 rounded-l-full" },
|
|
155
|
+
middle: { band: "-left-[1000px] -right-[1000px]" },
|
|
156
|
+
end: { band: "-left-[1000px] right-0 rounded-r-full" },
|
|
157
|
+
only: { bandTrack: "hidden" },
|
|
149
158
|
},
|
|
150
159
|
isToday: { true: {}, false: {} },
|
|
151
160
|
isOutside: { true: {}, false: {} },
|
|
@@ -25,7 +25,7 @@ whole-screen states.
|
|
|
25
25
|
| `screen-header.tsx` | `Screen.Header` |
|
|
26
26
|
| `screen-footer.tsx` | `Screen.Footer` |
|
|
27
27
|
| `screen-footer-background.tsx` | The footer's backing and its top hairline — internal |
|
|
28
|
-
| `screen-scroll-shadow.tsx` | `Screen.ScrollShadow`, and `SCROLL_SHADOW_EDGES` |
|
|
28
|
+
| `screen-scroll-shadow.tsx` | `Screen.ScrollShadow`, and its two axes — `SCROLL_SHADOW_EDGES` and `SCROLL_SHADOW_ANCHORS` |
|
|
29
29
|
| `screen-scroll-area.tsx` | `Screen.ScrollArea` |
|
|
30
30
|
| `screen-flat-list.tsx` | `Screen.FlatList` |
|
|
31
31
|
| `screen-section-list.tsx` | `Screen.SectionList` |
|
|
@@ -65,8 +65,10 @@ export type { ScreenNavbarTitleProps } from "./screen-navbar-title";
|
|
|
65
65
|
export type { ScreenRootProps } from "./screen-root";
|
|
66
66
|
export type { ScreenScrollAreaProps } from "./screen-scroll-area";
|
|
67
67
|
export {
|
|
68
|
+
SCROLL_SHADOW_ANCHORS,
|
|
68
69
|
SCROLL_SHADOW_EDGES,
|
|
69
70
|
ScreenScrollShadow,
|
|
71
|
+
type ScreenScrollShadowAnchor,
|
|
70
72
|
type ScreenScrollShadowEdge,
|
|
71
73
|
type ScreenScrollShadowProps,
|
|
72
74
|
} from "./screen-scroll-shadow";
|
|
@@ -80,3 +80,4 @@ components built on it (`Card`, `Alert`) can read the plane they sit on.
|
|
|
80
80
|
down; `SurfaceContext.material` is how that panel knows to take `rounded-xl`, one step under the
|
|
81
81
|
tray's corner. A single `shadow-*` utility sets one `box-shadow`, which is why light and dark are
|
|
82
82
|
two tokens joined by `dark:` and not one composed class. Opt-in: nothing in the default look uses it.
|
|
83
|
+
`SURFACE_MATERIALS` and `SurfaceMaterial` are exported from the index like every other axis.
|
|
@@ -22,7 +22,7 @@ scope — it is an input, not a text effect.
|
|
|
22
22
|
| `text-animation-caret.tsx` | Internal leaf: the typing caret |
|
|
23
23
|
| `text-animation-clock.ts` | `useTextAnimationClock` — a frame-callback elapsed-ms shared value, pausable, keyed by run |
|
|
24
24
|
| `text-animation.types.ts` | `TextAnimationBase`, the props every part shares |
|
|
25
|
-
| `text-animation.variants.ts` | The slotted `tv()` + every pure resolver: splitting, stagger, effect frames, typing timeline, number format, roll targets |
|
|
25
|
+
| `text-animation.variants.ts` | The slotted `tv()` + every pure resolver: splitting, stagger, effect frames, typing timeline, number format, roll targets — and the five axes (`TEXT_ANIMATION_UNITS`, `_ORDERS`, `_EFFECT_KINDS`, `_CARET_KINDS`, `_SLIDE_DIRECTIONS`), each exported with its union |
|
|
26
26
|
| `text-animation.variants.test.ts` | |
|
|
27
27
|
|
|
28
28
|
## The root is Reveal
|
|
@@ -19,11 +19,15 @@ export {
|
|
|
19
19
|
TEXT_ANIMATION_CARET_KINDS,
|
|
20
20
|
TEXT_ANIMATION_EFFECT_KINDS,
|
|
21
21
|
TEXT_ANIMATION_ORDERS,
|
|
22
|
+
TEXT_ANIMATION_SLIDE_DIRECTIONS,
|
|
22
23
|
TEXT_ANIMATION_UNITS,
|
|
23
24
|
type TextAnimationCaret,
|
|
25
|
+
type TextAnimationCaretKind,
|
|
24
26
|
type TextAnimationEffect,
|
|
27
|
+
type TextAnimationEffectKind,
|
|
25
28
|
type TextAnimationNumberFormat,
|
|
26
29
|
type TextAnimationOrder,
|
|
30
|
+
type TextAnimationSlideFrom,
|
|
27
31
|
type TextAnimationTransition,
|
|
28
32
|
type TextAnimationUnit,
|
|
29
33
|
textAnimationVariants,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
splitText,
|
|
24
24
|
TEXT_ANIMATION_CARET_KINDS,
|
|
25
25
|
TEXT_ANIMATION_ORDERS,
|
|
26
|
+
TEXT_ANIMATION_SLIDE_DIRECTIONS,
|
|
26
27
|
TEXT_ANIMATION_UNITS,
|
|
27
28
|
textAnimationVariants,
|
|
28
29
|
} from "./text-animation.variants";
|
|
@@ -205,6 +206,14 @@ describe("resolveEffectFrame", () => {
|
|
|
205
206
|
expect(resolveEffectFrame({ kind: "fade" }, 0)).toEqual({ opacity: 0, translateX: 0, translateY: 0, scale: 1 });
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
// The array is the public list of `from` values, so every entry must move the unit.
|
|
210
|
+
test("every slide direction starts off its rest position", () => {
|
|
211
|
+
for (const from of TEXT_ANIMATION_SLIDE_DIRECTIONS) {
|
|
212
|
+
const frame = resolveEffectFrame({ kind: "slide", from }, 0);
|
|
213
|
+
expect(Math.abs(frame.translateX) + Math.abs(frame.translateY)).toBe(12);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
208
217
|
test("slide starts its distance away on the side it comes from", () => {
|
|
209
218
|
expect(resolveEffectFrame({ kind: "slide", from: "up" }, 0).translateY).toBe(-12);
|
|
210
219
|
expect(resolveEffectFrame({ kind: "slide", from: "down" }, 0).translateY).toBe(12);
|
|
@@ -5,6 +5,7 @@ export const TEXT_ANIMATION_UNITS = ["character", "word", "line"] as const;
|
|
|
5
5
|
export const TEXT_ANIMATION_ORDERS = ["forward", "reverse", "center", "random"] as const;
|
|
6
6
|
export const TEXT_ANIMATION_EFFECT_KINDS = ["fade", "slide", "scale"] as const;
|
|
7
7
|
export const TEXT_ANIMATION_CARET_KINDS = ["none", "bar", "block"] as const;
|
|
8
|
+
export const TEXT_ANIMATION_SLIDE_DIRECTIONS = ["up", "down", "left", "right"] as const;
|
|
8
9
|
|
|
9
10
|
/** What one entering piece of a `Reveal` is. */
|
|
10
11
|
export type TextAnimationUnit = (typeof TEXT_ANIMATION_UNITS)[number];
|
|
@@ -12,7 +13,8 @@ export type TextAnimationUnit = (typeof TEXT_ANIMATION_UNITS)[number];
|
|
|
12
13
|
export type TextAnimationOrder = (typeof TEXT_ANIMATION_ORDERS)[number];
|
|
13
14
|
export type TextAnimationEffectKind = (typeof TEXT_ANIMATION_EFFECT_KINDS)[number];
|
|
14
15
|
export type TextAnimationCaretKind = (typeof TEXT_ANIMATION_CARET_KINDS)[number];
|
|
15
|
-
|
|
16
|
+
/** Where a `slide` effect enters from. */
|
|
17
|
+
export type TextAnimationSlideFrom = (typeof TEXT_ANIMATION_SLIDE_DIRECTIONS)[number];
|
|
16
18
|
|
|
17
19
|
/** How a unit arrives. Every kind fades in; `slide` and `scale` add a transform on top. */
|
|
18
20
|
export type TextAnimationEffect =
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const COMPONENTS = join(import.meta.dirname, "components");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every variant axis a component declares must reach its public entry point.
|
|
9
|
+
*
|
|
10
|
+
* An axis is a `const` array and the union read off it —
|
|
11
|
+
* `BADGE_MATERIALS` and `BadgeMaterial`. The union types a prop; the array is how
|
|
12
|
+
* a caller iterates the values, builds a picker or a story, or narrows a string
|
|
13
|
+
* from outside. `Badge`, `Button` and `Surface` each grew a public `material`
|
|
14
|
+
* prop whose pair stayed behind `index.ts`, so a caller could pass the prop but
|
|
15
|
+
* not name its type without reaching into a file the package does not export.
|
|
16
|
+
*
|
|
17
|
+
* Like `docs.test.ts`, this reads the tree as **text** and imports nothing from
|
|
18
|
+
* it: an axis is recognised by its declaration,
|
|
19
|
+
* `export type Name = (typeof NAME)[number]`, and both names must appear in the
|
|
20
|
+
* folder's `index.ts` export lists. Declaring an axis that way is declaring it
|
|
21
|
+
* public — an internal union is a plain string-literal type and is not checked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Axis = { file: string; type: string; values: string };
|
|
25
|
+
|
|
26
|
+
const AXIS = /^export type ([A-Za-z0-9_]+) = \(typeof ([A-Z0-9_]+)\)\[number\];/gm;
|
|
27
|
+
|
|
28
|
+
/** Every component folder's non-test modules, as relative path → source. */
|
|
29
|
+
function modules(folder: string): Map<string, string> {
|
|
30
|
+
const found = new Map<string, string>();
|
|
31
|
+
for (const entry of readdirSync(join(COMPONENTS, folder), { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isFile() || !/\.tsx?$/.test(entry.name) || entry.name.includes(".test.")) continue;
|
|
33
|
+
if (entry.name === "index.ts") continue;
|
|
34
|
+
found.set(`${folder}/${entry.name}`, readFileSync(join(COMPONENTS, folder, entry.name), "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The axes one module declares. */
|
|
40
|
+
function axesIn(file: string, source: string): Axis[] {
|
|
41
|
+
return [...source.matchAll(AXIS)].map(([, type, values]) => ({ file, type, values }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every name inside an `export { … }` or `export type { … }` list of an index. */
|
|
45
|
+
function exportedNames(index: string): Set<string> {
|
|
46
|
+
const names = new Set<string>();
|
|
47
|
+
for (const [, list] of index.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
|
48
|
+
for (const raw of list.split(",")) {
|
|
49
|
+
const name = raw
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^type\s+/, "")
|
|
52
|
+
.split(/\s+as\s+/)
|
|
53
|
+
.pop();
|
|
54
|
+
if (name) names.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FOLDERS = readdirSync(COMPONENTS, { withFileTypes: true })
|
|
61
|
+
.filter((entry) => entry.isDirectory())
|
|
62
|
+
.map((entry) => entry.name)
|
|
63
|
+
.sort();
|
|
64
|
+
|
|
65
|
+
const AXES = FOLDERS.flatMap((folder) => {
|
|
66
|
+
const exported = exportedNames(readFileSync(join(COMPONENTS, folder, "index.ts"), "utf-8"));
|
|
67
|
+
return [...modules(folder)].flatMap(([file, source]) => axesIn(file, source).map((axis) => ({ ...axis, exported })));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("variant axes", () => {
|
|
71
|
+
// A matcher that found nothing would pass every assertion below on an empty set.
|
|
72
|
+
test("finds the axes", () => {
|
|
73
|
+
expect(AXES.length).toBeGreaterThan(50);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("every axis union is exported from its component's index", () => {
|
|
77
|
+
const missing = AXES.filter((axis) => !axis.exported.has(axis.type)).map((axis) => `${axis.file}: ${axis.type}`);
|
|
78
|
+
expect(missing).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("every axis array is exported from its component's index", () => {
|
|
82
|
+
const missing = AXES.filter((axis) => !axis.exported.has(axis.values)).map(
|
|
83
|
+
(axis) => `${axis.file}: ${axis.values}`
|
|
84
|
+
);
|
|
85
|
+
expect(missing).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|