@godxjp/ui 27.12.1 → 28.1.0

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.
@@ -158,6 +158,63 @@ const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
158
158
  */
159
159
  const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
160
160
 
161
+ /**
162
+ * Primitives whose `asChild` hands over THE VISUAL BOX, so the borrowed element cannot be a
163
+ * `<Button>` — wrapping it would paint a button where a card belongs.
164
+ *
165
+ * This set is deliberately tiny and deliberately NOT "anything with asChild". The first cut of
166
+ * this exemption was the broad version, and measuring it against a real consumer showed what that
167
+ * costs: of 39 `no-raw-button` findings across its three SPAs, it silenced 11 — and
168
+ * every single one was a BEHAVIOUR trigger, not a box:
169
+ *
170
+ * DropdownMenuTrigger · SheetTrigger · CollapsibleTrigger · PopoverTrigger · TooltipTrigger
171
+ *
172
+ * A Radix trigger supplies behaviour and no styling, so `<DropdownMenuTrigger asChild><Button>`
173
+ * is not only legal, it is the better answer — the package's button, correctly styled, keyboard
174
+ * and screen-reader shaped. Exempting those threw away 11 legitimate nudges to fix 0 real
175
+ * findings. Adding a name here means asserting that primitive paints the box itself.
176
+ */
177
+ const ASCHILD_BOX_PRIMITIVES = new Set(["Card"]);
178
+
179
+ /**
180
+ * Is the raw tag at `index` the slot child of a primitive that hands over its BOX?
181
+ *
182
+ * `<Card asChild hoverable><button onClick={…}>` is not a violation — it is the shape this
183
+ * package PRESCRIBES. `Card.asChild`'s own docblock says so in as many words:
184
+ *
185
+ * "a card rendered as an `<a>` or a `<button>` measures byte for byte like the `div` it
186
+ * replaces and stays a SINGLE tab stop"
187
+ *
188
+ * and `hoverable`'s docblock sends the reader there: "the whole card rendered as one via
189
+ * {@link CardProps.asChild}".
190
+ *
191
+ * Reported as gh#740 and closed by shipping `Card asChild` — but the audit was never taught about
192
+ * it, so a consumer following the fix still got `no-raw-button: error` on the documented answer
193
+ * (godx-jp/shoots-gemba#8). A package that forbids its own prescription leaves the consumer no
194
+ * legal move at all, which is exactly what #740 was opened about.
195
+ *
196
+ * Walks BACKWARDS to the nearest preceding `>` and asks whether the tag it closes is one of
197
+ * ASCHILD_BOX_PRIMITIVES carrying `asChild`. That covers both spellings prettier produces —
198
+ * `<Card asChild><button` on one line, and the attribute wrapped onto its own line — while
199
+ * `</Card>` or `<div>` before the tag exempts nothing, because neither starts a component, and
200
+ * `<DropdownMenuTrigger asChild>` exempts nothing either, because a trigger paints no box.
201
+ */
202
+ const isAsChildSlot = (content, index) => {
203
+ const before = content.slice(0, index).replace(/\s+$/, "");
204
+ if (!before.endsWith(">")) return false;
205
+ const openStart = before.lastIndexOf("<", before.length - 1);
206
+ if (openStart === -1) return false;
207
+ const openTag = before.slice(openStart);
208
+ // A component, not `</Card>` and not a lowercase host element…
209
+ const name = /^<([A-Z][\w.]*)\b/.exec(openTag)?.[1];
210
+ // …and one that hands over its BOX, not merely its behaviour. See ASCHILD_BOX_PRIMITIVES.
211
+ return name !== undefined && ASCHILD_BOX_PRIMITIVES.has(name) && /\basChild\b/.test(openTag);
212
+ };
213
+
214
+ /** `rawTag`, minus the matches that are an `asChild` primitive's borrowed element. */
215
+ const rawTagOutsideAsChildSlot = (tag) => (content) =>
216
+ [...content.matchAll(rawTag(tag))].filter((match) => !isAsChildSlot(content, match.index));
217
+
161
218
  /**
162
219
  * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
163
220
  * exempt?:RegExp, classOnly?:boolean}[]}
@@ -409,6 +466,12 @@ const RULES = [
409
466
  severity: "error",
410
467
  spansElement: true,
411
468
  test: new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
469
+ matches: (content) =>
470
+ [
471
+ ...content.matchAll(
472
+ new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
473
+ ),
474
+ ].filter((match) => !isAsChildSlot(content, match.index)),
412
475
  message: "Use <Input> from @godxjp/ui, not a raw <input> (rules §3).",
413
476
  },
414
477
  {
@@ -418,6 +481,7 @@ const RULES = [
418
481
  severity: "error",
419
482
  spansElement: true,
420
483
  test: rawTag("button"),
484
+ matches: rawTagOutsideAsChildSlot("button"),
421
485
  message: "Use <Button> from @godxjp/ui, not a raw <button> (rules §3).",
422
486
  },
423
487
  {
@@ -601,10 +665,19 @@ const RULES = [
601
665
  /*
602
666
  * A lucide element rendered where NOTHING will size it.
603
667
  *
604
- * A lucide component ships `width="24" height="24"`, and exactly four rules in this library
605
- * override that: `.ui-button svg`, `.ui-dropdown-menu-item > svg`, `.ui-topbar-item svg` and
606
- * `[data-slot="list-row-leading"] > svg`. Outside them — in a `Text`, a table cell, an `<a>`,
607
- * a `Flex` — the glyph draws at 24px beside 14px type. It is the one defect class that is
668
+ * A lucide component ships `width="24" height="24"`, and this library overrides that in
669
+ * **31** selectors (counted 2026-09-19 across `src/styles/*.css`; this comment used to say
670
+ * "exactly four" and name them, which had drifted far enough to cause gh#755). Most are
671
+ * component-internal — a consumer cannot put a glyph inside `.ui-rating-star` — so the number
672
+ * is not the exemption list and never was.
673
+ *
674
+ * What a consumer CAN reach is a SLOT, and that is what `iconSizingRanges` keys on: the slot's
675
+ * NAME at the call site, not the selector that ends up sizing it. That is why the list there
676
+ * is prop names rather than CSS, and why it must cover both spellings a slot takes —
677
+ * `icon={<Users />}` and `icon: <Users />` inside an items array (gh#755).
678
+ *
679
+ * Outside a slot — in a `Text`, a table cell, an `<a>`, a `Flex` — the glyph draws at 24px
680
+ * beside 14px type. It is the one defect class that is
608
681
  * INVISIBLE in review: the JSX is correct, the import is correct, and only the screen is
609
682
  * wrong. A consumer swept one app and found 38 (gh#712).
610
683
  *
@@ -1051,9 +1124,52 @@ function iconSizingRanges(source) {
1051
1124
  const close = matchBracket(source, open);
1052
1125
  ranges.push([open, close < 0 ? source.length : close + 1]);
1053
1126
  }
1127
+ /*
1128
+ * The SAME slots, written as an object property instead of a JSX prop (gh#755).
1129
+ *
1130
+ * <Tabs items={saved.map((s) => ({ icon: s.shared ? <Users /> : undefined }))} />
1131
+ *
1132
+ * `Tabs` wraps `items[].icon` in `<span class="ui-tabs-trigger-icon">`, and
1133
+ * `navigation-layout.css` sizes `.ui-tabs-trigger-icon svg` — so the glyph IS measured, by the
1134
+ * same component that owns the slot. But the prop form above requires `=`, so `icon:` matched
1135
+ * nothing and the call site was told to size a box the design system owns — the opposite of
1136
+ * what CONSUMER-RULES asks for.
1137
+ *
1138
+ * A property value is not bracketed like `={...}`, so the range runs to the `,` or `}` that
1139
+ * closes it at depth zero. Strings and nested brackets are skipped so a comma inside
1140
+ * `style={{a: 1, b: 2}}` or inside a string does not end it early.
1141
+ */
1142
+ for (const m of source.matchAll(
1143
+ /\b(?:icon|leading|trailing|mark|indicator|avatar|prefix|suffix|addonBefore|addonAfter)\s*:/g,
1144
+ )) {
1145
+ const from = m.index + m[0].length;
1146
+ ranges.push([from, objectPropertyValueEnd(source, from)]);
1147
+ }
1054
1148
  return ranges;
1055
1149
  }
1056
1150
 
1151
+ /** Where an object property's value ends: the `,` or `}` closing it at depth zero. */
1152
+ function objectPropertyValueEnd(source, from) {
1153
+ let depth = 0;
1154
+ for (let i = from; i < source.length; i += 1) {
1155
+ const c = source[i];
1156
+ if (c === '"' || c === "'" || c === "`") {
1157
+ // Skip to the closing quote of the same kind, honouring backslash escapes.
1158
+ const quote = c;
1159
+ i += 1;
1160
+ while (i < source.length && source[i] !== quote) i += source[i] === "\\" ? 2 : 1;
1161
+ continue;
1162
+ }
1163
+ if (c === "(" || c === "[" || c === "{") depth += 1;
1164
+ else if (c === ")" || c === "]") depth -= 1;
1165
+ else if (c === "}") {
1166
+ if (depth === 0) return i;
1167
+ depth -= 1;
1168
+ } else if (c === "," && depth === 0) return i;
1169
+ }
1170
+ return source.length;
1171
+ }
1172
+
1057
1173
  /** A lucide element that no rule, no slot and no author-supplied size will ever measure. */
1058
1174
  function* lucideGlyphMatches(source) {
1059
1175
  const names = lucideLocalNames(source);