@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-alpha.20261021071515",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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:** `band` (absolute, edge to edge, behind), `dayBase` (the circle) and
79
- the label. The band spans the full cell so adjacent days join into one strip.
80
- - **The band ends at the centre of each end cell, and the circle is the cap.** Start runs from its
81
- cell's centre to the right edge, end from the left edge to its centre, middle spans the cell; none
82
- is rounded. A cell is wider than its circle, so the first version — a band rounded across the
83
- whole end cell — showed a crescent of band beside the circle at both ends. A lone `only` day
84
- draws no band. At a week's edge the strip simply stops and resumes on the next row.
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 ? <View className={slots.band()} /> : null}
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 bandOf = (rangeRole: CalendarRangeRole) => calendarVariants({ rangeRole }).band();
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 stops at its cell's centre, under the circle.
118
- test("start runs from the cell's centre to its right edge", () => {
119
- expect(bandOf("start")).toMatch(/\bleft-1\/2\b/);
120
- expect(bandOf("start")).toMatch(/\bright-0\b/);
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("end runs from the cell's left edge to its centre", () => {
124
- expect(bandOf("end")).toMatch(/\bleft-0\b/);
125
- expect(bandOf("end")).toMatch(/\bright-1\/2\b/);
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("middle spans the whole cell, square", () => {
129
- expect(bandOf("middle")).toMatch(/\binset-x-0\b/);
130
- expect(bandOf("middle")).not.toMatch(/rounded-(?:l-|r-)?full/);
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("no band end is rounded — the circle is the cap", () => {
134
- for (const rangeRole of ["start", "middle", "end"] as const) {
135
- expect(bandOf(rangeRole)).not.toMatch(/rounded-(?:l-|r-)?full/);
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(bandOf("only")).toContain("hidden");
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.** Start runs from its cell's centre to the
58
- * right edge, end from the left edge to its centre, and every day between spans the whole cell
59
- * square — including at a week's edge, where the strip simply stops and picks up again on the
60
- * next row. A cell is wider than its circle, so a band rounded across the whole end cell poked a
61
- * crescent out beside the circle; stopping at the centre lets the circle itself be the cap. A
62
- * one-day range draws no band at all.
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 strip behind a day inside a range. */
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: { band: "hidden" },
145
- start: { band: "left-1/2 right-0" },
146
- middle: { band: "inset-x-0" },
147
- end: { band: "left-0 right-1/2" },
148
- only: { band: "hidden" },
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.
@@ -5,8 +5,10 @@ export {
5
5
  resolveSurfaceVariant,
6
6
  SURFACE_FILLED_VARIANTS,
7
7
  SURFACE_FOREGROUND_TOKENS,
8
+ SURFACE_MATERIALS,
8
9
  SURFACE_PADDINGS,
9
10
  SURFACE_VARIANTS,
11
+ type SurfaceMaterial,
10
12
  type SurfacePadding,
11
13
  type SurfacePlane,
12
14
  type SurfaceVariant,
@@ -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
- export type TextAnimationSlideFrom = "up" | "down" | "left" | "right";
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
+ });