@godxjp/ui 28.0.0 → 28.2.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.
@@ -0,0 +1,344 @@
1
+ import { useState } from "react";
2
+
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@godxjp/ui/data-display";
4
+ import { Button, Text } from "@godxjp/ui/general";
5
+ import { Flex, PageContainer } from "@godxjp/ui/layout";
6
+ import { Tabs } from "@godxjp/ui/navigation";
7
+
8
+ /**
9
+ * CARD TABS — the strip and its panel as ONE object (gh#762).
10
+ *
11
+ * The screen this page is built from is the one the issue draws in ASCII: a saved-view strip over
12
+ * a list, antd `type="editable-card"` shaped — closable views, a `+`, an action parked on the
13
+ * trailing edge of the bar, and a body that the open tab opens INTO rather than sits above.
14
+ *
15
+ * Everything here is `Tabs` with documented props. There is no page-local CSS and no wrapper box
16
+ * around the panel: the point of `bodied` is that the consumer no longer has to guess which box
17
+ * to put underneath, because all three guesses were wrong (`<Card>` added a second 1px border at
18
+ * a 9.708px radius over an 8px gap; `<Card variant="borderless">` and no box at all left the same
19
+ * 8px gap). Measured after: 0px of gap, one continuous 1px line, and no line at all across the
20
+ * active tab.
21
+ */
22
+
23
+ type SavedView = {
24
+ value: string;
25
+ label: string;
26
+ count: number;
27
+ /** antd `Tab.closable` — 「すべての課題」 is the system view and cannot be deleted. */
28
+ closable?: boolean;
29
+ rows: string[];
30
+ };
31
+
32
+ const INITIAL_VIEWS: SavedView[] = [
33
+ {
34
+ value: "all",
35
+ label: "すべての課題",
36
+ count: 340,
37
+ closable: false,
38
+ rows: [
39
+ "TASK-1042 · 請求書の様式を更新",
40
+ "TASK-1041 · 監査ログの保存期間",
41
+ "TASK-1038 · 権限表の棚卸",
42
+ ],
43
+ },
44
+ {
45
+ value: "mine",
46
+ label: "自分の担当",
47
+ count: 12,
48
+ rows: ["TASK-1042 · 請求書の様式を更新", "TASK-1029 · 締め処理の手順書"],
49
+ },
50
+ {
51
+ value: "review",
52
+ label: "レビュー待ち",
53
+ count: 0,
54
+ rows: [],
55
+ },
56
+ ];
57
+
58
+ /** The list body a saved view opens into — a real panel, not a placeholder paragraph. */
59
+ function ViewBody({ view }: { view: SavedView }) {
60
+ if (view.rows.length === 0) {
61
+ return (
62
+ <Text as="p" tone="muted">
63
+ この条件に一致する課題はありません。
64
+ </Text>
65
+ );
66
+ }
67
+ return (
68
+ <Flex direction="col" gap="sm">
69
+ {view.rows.map((row) => (
70
+ <Text key={row} as="p">
71
+ {row}
72
+ </Text>
73
+ ))}
74
+ </Flex>
75
+ );
76
+ }
77
+
78
+ const PLACEMENT_ITEMS = [
79
+ { value: "summary", label: "サマリ", content: <Text as="p">当月の売上合計 ¥4,820,000</Text> },
80
+ { value: "detail", label: "明細", content: <Text as="p">明細 24 件 · 未承認 2 件</Text> },
81
+ { value: "audit", label: "監査", content: <Text as="p">監査ログ 96 件</Text> },
82
+ ];
83
+
84
+ const COUNT_ITEMS = [
85
+ {
86
+ value: "open",
87
+ label: "未対応",
88
+ count: 12,
89
+ countLabel: "件の課題",
90
+ content: <Text as="p">未対応 12 件</Text>,
91
+ },
92
+ {
93
+ value: "doing",
94
+ label: "対応中",
95
+ count: 340,
96
+ overflowCount: 999,
97
+ countLabel: "件の課題",
98
+ content: <Text as="p">対応中 340 件</Text>,
99
+ },
100
+ {
101
+ value: "capped",
102
+ label: "全期間",
103
+ count: 1280,
104
+ countLabel: "件の課題",
105
+ content: <Text as="p">全期間 1,280 件 · 既定の overflowCount は 99</Text>,
106
+ },
107
+ {
108
+ value: "empty",
109
+ label: "保留",
110
+ count: 0,
111
+ countLabel: "件の課題",
112
+ content: <Text as="p">保留 0 件</Text>,
113
+ },
114
+ {
115
+ value: "hidden-zero",
116
+ label: "却下",
117
+ count: 0,
118
+ showZero: false,
119
+ countLabel: "件の課題",
120
+ content: <Text as="p">showZero={"{false}"} なので 0 のピルは描かれない</Text>,
121
+ },
122
+ {
123
+ value: "off",
124
+ label: "アーカイブ",
125
+ disabled: true,
126
+ count: 7,
127
+ countLabel: "件の課題",
128
+ content: <Text as="p">アーカイブ</Text>,
129
+ },
130
+ ];
131
+
132
+ export default function Demo() {
133
+ const [views, setViews] = useState(INITIAL_VIEWS);
134
+ const [activeView, setActiveView] = useState("all");
135
+ const [nextView, setNextView] = useState(1);
136
+
137
+ return (
138
+ <PageContainer
139
+ title="カードタブ"
140
+ subtitle="ストリップとパネルをひとつの箱にする bodied と、タブの件数バッジ。"
141
+ >
142
+ <Flex direction="col" gap="lg">
143
+ {/* THE SCREEN THE ISSUE DRAWS — saved views over a list. */}
144
+ <Card>
145
+ <CardHeader>
146
+ <CardTitle level={2}>保存したビュー · editable-card + bodied</CardTitle>
147
+ <CardDescription>
148
+ antd の <code>type=&quot;editable-card&quot;</code> と同じ形。
149
+ <code>bodied</code> がパネルの枠・角丸・背景を描くので、ストリップとパネルが
150
+ ひとつの箱として読める。「すべての課題」は <code>closable: false</code>
151
+ なので × が出ない。
152
+ </CardDescription>
153
+ </CardHeader>
154
+ <CardContent>
155
+ <Tabs
156
+ id="saved-views"
157
+ variant="editable-card"
158
+ bodied
159
+ value={activeView}
160
+ onValueChange={setActiveView}
161
+ extra={
162
+ <Button size="sm" variant="outline">
163
+ フィルタを保存
164
+ </Button>
165
+ }
166
+ onEdit={(target, action) => {
167
+ if (action === "add") {
168
+ const value = `view-${nextView}`;
169
+ setViews((prev) => [
170
+ ...prev,
171
+ { value, label: `新しいビュー ${nextView}`, count: 0, rows: [] },
172
+ ]);
173
+ setNextView((n) => n + 1);
174
+ setActiveView(value);
175
+ return;
176
+ }
177
+ const key = String(target);
178
+ setViews((prev) => prev.filter((view) => view.value !== key));
179
+ setActiveView((current) =>
180
+ current === key ? (views.find((v) => v.value !== key)?.value ?? "all") : current,
181
+ );
182
+ }}
183
+ items={views.map((view) => ({
184
+ value: view.value,
185
+ label: view.label,
186
+ closable: view.closable,
187
+ count: view.count,
188
+ overflowCount: 999,
189
+ countLabel: "件の課題",
190
+ content: <ViewBody view={view} />,
191
+ }))}
192
+ />
193
+ </CardContent>
194
+ </Card>
195
+
196
+ {/* THE DEFECT, SIDE BY SIDE. */}
197
+ <Card>
198
+ <CardHeader>
199
+ <CardTitle level={2}>bodied なし / あり</CardTitle>
200
+ <CardDescription>
201
+ <code>bodied</code> なしの card ストリップは 8px 離れた透明なパネルの上に浮く —「card
202
+ と tab が 1 つに融合していない」と報告された形。あり側は隙間 0px、 線は 1
203
+ 本、アクティブなタブの下辺だけが開いている。
204
+ </CardDescription>
205
+ </CardHeader>
206
+ <CardContent>
207
+ <Flex direction="col" gap="lg">
208
+ <Flex direction="col" gap="sm">
209
+ <Text as="p" size="sm" tone="muted">
210
+ bodied なし(28.0.0 まで)
211
+ </Text>
212
+ <Tabs
213
+ id="card-unbodied"
214
+ defaultValue="summary"
215
+ variant="card"
216
+ items={PLACEMENT_ITEMS}
217
+ />
218
+ </Flex>
219
+ <Flex direction="col" gap="sm">
220
+ <Text as="p" size="sm" tone="muted">
221
+ bodied
222
+ </Text>
223
+ <Tabs
224
+ id="card-bodied"
225
+ defaultValue="summary"
226
+ variant="card"
227
+ bodied
228
+ items={PLACEMENT_ITEMS}
229
+ />
230
+ </Flex>
231
+ </Flex>
232
+ </CardContent>
233
+ </Card>
234
+
235
+ {/* EVERY PLACEMENT — the joined edge follows the strip. */}
236
+ <Card>
237
+ <CardHeader>
238
+ <CardTitle level={2}>bodied × tabPlacement</CardTitle>
239
+ <CardDescription>
240
+ 角丸が落ちる辺・枠が重なる辺は、ストリップの位置に従う。
241
+ <code>start</code> / <code>end</code> は論理値なので RTL でも反転する (48rem 以下では
242
+ top / bottom に折り返す)。
243
+ </CardDescription>
244
+ </CardHeader>
245
+ <CardContent>
246
+ <Flex direction="col" gap="lg">
247
+ {(["top", "bottom", "start", "end"] as const).map((placement) => (
248
+ <Flex key={placement} direction="col" gap="sm">
249
+ <Text as="p" size="sm" tone="muted">
250
+ tabPlacement=&quot;{placement}&quot;
251
+ </Text>
252
+ <Tabs
253
+ id={`card-bodied-${placement}`}
254
+ defaultValue="summary"
255
+ variant="card"
256
+ bodied
257
+ tabPlacement={placement}
258
+ items={PLACEMENT_ITEMS}
259
+ />
260
+ </Flex>
261
+ ))}
262
+ </Flex>
263
+ </CardContent>
264
+ </Card>
265
+
266
+ {/* THE COUNT. */}
267
+ <Card>
268
+ <CardHeader>
269
+ <CardTitle level={2}>タブの件数バッジ · count / countLabel</CardTitle>
270
+ <CardDescription>
271
+ <code>count</code> は Button・Toggle と同じカウンタ語彙で、
272
+ <code>Intl.NumberFormat</code> でロケール整形される。読み上げ名は 「未対応, 12
273
+ 件の課題」 — 数字が直接ラベルに連結しないよう、ピルは <code>aria-hidden</code>、
274
+ 読み上げ用の文だけが <code>sr-only</code> で隣に置かれる。 既定の上限は 99(
275
+ <code>overflowCount</code> で変更)、 0 を隠すには <code>showZero={"{false}"}</code>。
276
+ </CardDescription>
277
+ </CardHeader>
278
+ <CardContent>
279
+ <Flex direction="col" gap="lg">
280
+ <Flex direction="col" gap="sm">
281
+ <Text as="p" size="sm" tone="muted">
282
+ variant=&quot;line&quot;
283
+ </Text>
284
+ <Tabs id="count-line" defaultValue="open" variant="line" items={COUNT_ITEMS} />
285
+ </Flex>
286
+ <Flex direction="col" gap="sm">
287
+ <Text as="p" size="sm" tone="muted">
288
+ variant=&quot;card&quot; + bodied
289
+ </Text>
290
+ <Tabs
291
+ id="count-card"
292
+ defaultValue="open"
293
+ variant="card"
294
+ bodied
295
+ items={COUNT_ITEMS}
296
+ />
297
+ </Flex>
298
+ <Flex direction="col" gap="sm">
299
+ <Text as="p" size="sm" tone="muted">
300
+ size=&quot;sm&quot; / &quot;md&quot; / &quot;lg&quot;
301
+ </Text>
302
+ {(["sm", "md", "lg"] as const).map((size) => (
303
+ <Tabs
304
+ key={size}
305
+ id={`count-${size}`}
306
+ defaultValue="open"
307
+ variant="line"
308
+ size={size}
309
+ items={COUNT_ITEMS.slice(0, 3)}
310
+ />
311
+ ))}
312
+ </Flex>
313
+ </Flex>
314
+ </CardContent>
315
+ </Card>
316
+
317
+ {/* The other joined pair the package already ships. */}
318
+ <Card>
319
+ <CardHeader>
320
+ <CardTitle level={2}>もうひとつの「ひとつの箱」· Card tabList</CardTitle>
321
+ <CardDescription>
322
+ ストリップがカードの見出しの下に入る形は <code>Card tabList</code> が担当する (antd
323
+ `Card` の <code>tabList</code>)。枠はカードのもので、
324
+ <code>bodied</code> は要らない。card 系のストリップを単体で置くときだけ
325
+ <code>bodied</code> を使う。
326
+ </CardDescription>
327
+ </CardHeader>
328
+ <CardContent>
329
+ <Card
330
+ tabList={[
331
+ { key: "all", tab: "すべての課題", closable: false },
332
+ { key: "mine", tab: "自分の担当" },
333
+ ]}
334
+ >
335
+ <CardContent>
336
+ <Text as="p">カードの枠がそのままパネルの枠になる。</Text>
337
+ </CardContent>
338
+ </Card>
339
+ </CardContent>
340
+ </Card>
341
+ </Flex>
342
+ </PageContainer>
343
+ );
344
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "28.0.0",
4
- "godxUiMcp": "28.0.0",
3
+ "version": "28.2.0",
4
+ "godxUiMcp": "28.2.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -159,7 +159,25 @@ const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
159
159
  const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
160
160
 
161
161
  /**
162
- * Is the raw tag at `index` the SLOT CHILD of an `asChild` primitive?
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?
163
181
  *
164
182
  * `<Card asChild hoverable><button onClick={…}>` is not a violation — it is the shape this
165
183
  * package PRESCRIBES. `Card.asChild`'s own docblock says so in as many words:
@@ -175,10 +193,11 @@ const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
175
193
  * (godx-jp/shoots-gemba#8). A package that forbids its own prescription leaves the consumer no
176
194
  * legal move at all, which is exactly what #740 was opened about.
177
195
  *
178
- * Walks BACKWARDS to the nearest preceding `>` and asks whether the tag it closes is a
179
- * capitalised component carrying `asChild`. That covers both spellings prettier produces —
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 —
180
198
  * `<Card asChild><button` on one line, and the attribute wrapped onto its own line — while
181
- * `</Card>` or `<div>` before the tag exempts nothing, because neither starts a component.
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.
182
201
  */
183
202
  const isAsChildSlot = (content, index) => {
184
203
  const before = content.slice(0, index).replace(/\s+$/, "");
@@ -186,8 +205,10 @@ const isAsChildSlot = (content, index) => {
186
205
  const openStart = before.lastIndexOf("<", before.length - 1);
187
206
  if (openStart === -1) return false;
188
207
  const openTag = before.slice(openStart);
189
- // A component, not `</Card>` and not a lowercase host element.
190
- return /^<[A-Z][\w.]*\b/.test(openTag) && /\basChild\b/.test(openTag);
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);
191
212
  };
192
213
 
193
214
  /** `rawTag`, minus the matches that are an `asChild` primitive's borrowed element. */
@@ -644,10 +665,19 @@ const RULES = [
644
665
  /*
645
666
  * A lucide element rendered where NOTHING will size it.
646
667
  *
647
- * A lucide component ships `width="24" height="24"`, and exactly four rules in this library
648
- * override that: `.ui-button svg`, `.ui-dropdown-menu-item > svg`, `.ui-topbar-item svg` and
649
- * `[data-slot="list-row-leading"] > svg`. Outside them — in a `Text`, a table cell, an `<a>`,
650
- * 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
651
681
  * INVISIBLE in review: the JSX is correct, the import is correct, and only the screen is
652
682
  * wrong. A consumer swept one app and found 38 (gh#712).
653
683
  *
@@ -1094,9 +1124,52 @@ function iconSizingRanges(source) {
1094
1124
  const close = matchBracket(source, open);
1095
1125
  ranges.push([open, close < 0 ? source.length : close + 1]);
1096
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
+ }
1097
1148
  return ranges;
1098
1149
  }
1099
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
+
1100
1173
  /** A lucide element that no rule, no slot and no author-supplied size will ever measure. */
1101
1174
  function* lucideGlyphMatches(source) {
1102
1175
  const names = lucideLocalNames(source);