@godxjp/ui 27.11.0 → 27.12.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.
@@ -40,10 +40,31 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
40
40
  *
41
41
  * This is a PRESENTATION flag, not an interaction: it announces nothing and binds no handler.
42
42
  * A card that looks clickable has to BE clickable for everyone, so pair it with a real control
43
- * — a `Link`/`Button` in the header or footer, or the whole card rendered as one — never with a
44
- * bare `onClick` on this div, which a keyboard or screen-reader user cannot reach.
43
+ * — a `Link`/`Button` in the header or footer, or the whole card rendered as one via
44
+ * {@link CardProps.asChild} — never with a bare `onClick` on this div, which a keyboard or
45
+ * screen-reader user cannot reach.
45
46
  */
46
47
  hoverable?: boolean;
48
+ /**
49
+ * Borrow the child's element for the card box instead of rendering a `div` — the package's
50
+ * standard Slot passthrough (`Button`, `AspectRatio`, `ListRow`). The card's chrome and every
51
+ * `data-*` flag move onto that element, so a card rendered as an `<a>` or a `<button>` measures
52
+ * byte for byte like the `div` it replaces and stays a SINGLE tab stop.
53
+ *
54
+ * This is the other half of `hoverable`: `<Card asChild hoverable><a href="…">` is "the whole
55
+ * card rendered as one control", reachable by keyboard and announced as one link.
56
+ *
57
+ * NESTING CAVEAT — the card is now ONE control, so it may not contain another. An
58
+ * `<a>`/`<button>` (a `Link`, a `Button`, a menu trigger) inside a card-as-link or
59
+ * card-as-button is invalid HTML: the inner control is unreachable or mis-announced, and
60
+ * `tabList` — a strip of `button` triggers — is for the same reason not drawn under `asChild`.
61
+ * A card that needs interactive children is not one control: drop `asChild` and put the
62
+ * `Link`/`Button` inside it instead, as `hoverable` also allows.
63
+ *
64
+ * Exactly one child, as everywhere else in this package: two children have no element to
65
+ * borrow, so `Slot`'s `React.Children.only` throws — the same error `Button asChild` throws.
66
+ */
67
+ asChild?: boolean;
47
68
  /**
48
69
  * Ant Design `tabList` — the tab strip that lives IN THE CARD'S HEAD, under the title, inside
49
70
  * the same border and on the same surface, so the card and its tabs read as one object. The
@@ -102,10 +123,31 @@ export declare const Card: React.ForwardRefExoticComponent<React.HTMLAttributes<
102
123
  *
103
124
  * This is a PRESENTATION flag, not an interaction: it announces nothing and binds no handler.
104
125
  * A card that looks clickable has to BE clickable for everyone, so pair it with a real control
105
- * — a `Link`/`Button` in the header or footer, or the whole card rendered as one — never with a
106
- * bare `onClick` on this div, which a keyboard or screen-reader user cannot reach.
126
+ * — a `Link`/`Button` in the header or footer, or the whole card rendered as one via
127
+ * {@link CardProps.asChild} — never with a bare `onClick` on this div, which a keyboard or
128
+ * screen-reader user cannot reach.
107
129
  */
108
130
  hoverable?: boolean;
131
+ /**
132
+ * Borrow the child's element for the card box instead of rendering a `div` — the package's
133
+ * standard Slot passthrough (`Button`, `AspectRatio`, `ListRow`). The card's chrome and every
134
+ * `data-*` flag move onto that element, so a card rendered as an `<a>` or a `<button>` measures
135
+ * byte for byte like the `div` it replaces and stays a SINGLE tab stop.
136
+ *
137
+ * This is the other half of `hoverable`: `<Card asChild hoverable><a href="…">` is "the whole
138
+ * card rendered as one control", reachable by keyboard and announced as one link.
139
+ *
140
+ * NESTING CAVEAT — the card is now ONE control, so it may not contain another. An
141
+ * `<a>`/`<button>` (a `Link`, a `Button`, a menu trigger) inside a card-as-link or
142
+ * card-as-button is invalid HTML: the inner control is unreachable or mis-announced, and
143
+ * `tabList` — a strip of `button` triggers — is for the same reason not drawn under `asChild`.
144
+ * A card that needs interactive children is not one control: drop `asChild` and put the
145
+ * `Link`/`Button` inside it instead, as `hoverable` also allows.
146
+ *
147
+ * Exactly one child, as everywhere else in this package: two children have no element to
148
+ * borrow, so `Slot`'s `React.Children.only` throws — the same error `Button asChild` throws.
149
+ */
150
+ asChild?: boolean;
109
151
  /**
110
152
  * Ant Design `tabList` — the tab strip that lives IN THE CARD'S HEAD, under the title, inside
111
153
  * the same border and on the same surface, so the card and its tabs read as one object. The
@@ -2,6 +2,8 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { padStyle, padStepToken } from "../../lib/variants.js";
4
4
  import * as React from "react";
5
+ import { isDevelopment } from "../../lib/dev.js";
6
+ import { Slot } from "../../lib/slot.js";
5
7
  import { cn } from "../../lib/utils.js";
6
8
  import { Tabs } from "../navigation/tabs.js";
7
9
  function resolveActiveCardTabKey(tabList, requested) {
@@ -37,11 +39,20 @@ const Card = React.forwardRef(
37
39
  onTabChange,
38
40
  extra,
39
41
  tabProps,
42
+ asChild = false,
40
43
  children,
41
44
  ...props
42
45
  }, ref) => {
46
+ const slotted = asChild && Boolean(tabList?.length);
47
+ if (isDevelopment() && slotted) {
48
+ console.warn(
49
+ "Card: `asChild` renders the whole card as ONE control, so `tabList` is ignored and the tab strip is NOT drawn \u2014 a strip of `button` triggers inside a link/button is invalid HTML. Drop `asChild` and put a `Link`/`Button` inside the card instead."
50
+ );
51
+ }
52
+ const tabs = slotted ? void 0 : tabList;
53
+ const Comp = asChild ? Slot : "div";
43
54
  const [mirroredKey, setMirroredKey] = React.useState(void 0);
44
- const activeKey = tabList?.length ? resolveActiveCardTabKey(tabList, activeTabKey ?? mirroredKey ?? defaultActiveTabKey) : void 0;
55
+ const activeKey = tabs?.length ? resolveActiveCardTabKey(tabs, activeTabKey ?? mirroredKey ?? defaultActiveTabKey) : void 0;
45
56
  const handleTabChange = React.useCallback(
46
57
  (key) => {
47
58
  setMirroredKey(key);
@@ -49,9 +60,9 @@ const Card = React.forwardRef(
49
60
  },
50
61
  [onTabChange]
51
62
  );
52
- const { head, body, foot } = tabList?.length ? splitCardBands(children) : { head: void 0, body: void 0, foot: void 0 };
63
+ const { head, body, foot } = tabs?.length ? splitCardBands(children) : { head: void 0, body: void 0, foot: void 0 };
53
64
  return /* @__PURE__ */ jsx(
54
- "div",
65
+ Comp,
55
66
  {
56
67
  ref,
57
68
  className: cn("group/card", className),
@@ -61,16 +72,16 @@ const Card = React.forwardRef(
61
72
  "data-accent-placement": accentPlacement === "perimeter" ? "perimeter" : void 0,
62
73
  "data-variant": variant && variant !== "default" ? variant : void 0,
63
74
  "data-density": density,
64
- "data-tab-list": tabList?.length ? "" : void 0,
75
+ "data-tab-list": tabs?.length ? "" : void 0,
65
76
  ...props,
66
- children: tabList?.length ? /* @__PURE__ */ jsxs(Fragment, { children: [
77
+ children: tabs?.length ? /* @__PURE__ */ jsxs(Fragment, { children: [
67
78
  head,
68
79
  /* @__PURE__ */ jsx(
69
80
  Tabs,
70
81
  {
71
82
  variant: "line",
72
83
  ...tabProps,
73
- items: tabList.map((item) => ({
84
+ items: tabs.map((item) => ({
74
85
  value: item.key,
75
86
  label: item.tab,
76
87
  disabled: item.disabled,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "27.11.0",
3
+ "version": "27.12.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -2851,7 +2851,7 @@ export declare const COMPONENT_PROP_REGISTRY: {
2851
2851
  readonly CardProp: {
2852
2852
  readonly group: "data-display";
2853
2853
  readonly file: "components/data-display/card.tsx";
2854
- readonly vocabulary: readonly ["ToneProp", "ClassNameProp", "ChildrenProp"];
2854
+ readonly vocabulary: readonly ["ToneProp", "ClassNameProp", "ChildrenProp", "AsChildProp"];
2855
2855
  };
2856
2856
  readonly CardTabItemProp: {
2857
2857
  readonly group: "data-display";
@@ -3366,7 +3366,7 @@ const COMPONENT_PROP_REGISTRY = {
3366
3366
  CardProp: {
3367
3367
  group: "data-display",
3368
3368
  file: "components/data-display/card.tsx",
3369
- vocabulary: ["ToneProp", "ClassNameProp", "ChildrenProp"]
3369
+ vocabulary: ["ToneProp", "ClassNameProp", "ChildrenProp", "AsChildProp"]
3370
3370
  },
3371
3371
  CardTabItemProp: {
3372
3372
  group: "data-display",
@@ -20,6 +20,10 @@
20
20
  overflow: hidden;
21
21
  }
22
22
 
23
+ button[data-slot="card"] {
24
+ text-align: inherit;
25
+ }
26
+
23
27
  [data-slot="card"][data-variant="muted"] {
24
28
  background: hsl(var(--secondary));
25
29
  }
@@ -35,6 +35,9 @@
35
35
  .ui-carousel-dot,
36
36
  .ui-rating-star,
37
37
  a.ui-list-row,
38
+
39
+ a[data-slot="card"],
40
+ button[data-slot="card"],
38
41
  .ui-text[data-link],
39
42
  .sb-nav-item,
40
43
  .sb-user,
@@ -96,6 +99,8 @@
96
99
  .ui-carousel-dot,
97
100
  .ui-rating-star,
98
101
  a.ui-list-row,
102
+ a[data-slot="card"],
103
+ button[data-slot="card"],
99
104
  .ui-text[data-link],
100
105
  .sb-nav-item,
101
106
  .sb-user,
@@ -160,6 +160,47 @@ export default function Demo() {
160
160
  </ResponsiveGrid>
161
161
  </Flex>
162
162
 
163
+ <Flex direction="col" gap="sm">
164
+ <Text as="div" weight="medium">
165
+ asChild · カード全体をひとつのコントロールにする
166
+ </Text>
167
+ <ResponsiveGrid columns={{ sm: 1, md: 2 }}>
168
+ {/*
169
+ * `hoverable` が約束する「カード全体をひとつのコントロールとして描く」側。
170
+ * `asChild` はカードの箱を子の要素に譲るので、リンクそのものがカードになり、
171
+ * Tab は一度しか止まらず、スクリーンリーダーはリンクひとつとして読み上げます。
172
+ * 入れ子の禁則:これはもうコントロールなので、中に <a>/<Button> は置けません
173
+ * (HTML として不正)。中にボタンが要るカードは asChild を外し、hoverable +
174
+ * CardFooter のボタン(上の例)にします。
175
+ */}
176
+ <Card asChild hoverable>
177
+ <a href="#card-as-child">
178
+ <CardHeader>
179
+ <CardTitle level={2}>カード全体がリンク</CardTitle>
180
+ <CardDescription>
181
+ タブ 1 回で届き、フォーカスリングはカードの箱に出ます
182
+ </CardDescription>
183
+ </CardHeader>
184
+ <CardContent>
185
+ 詳細画面へ遷移します。onClick しか無い場合もこの形(router の Link
186
+ コンポーネントを子に渡す)にします。
187
+ </CardContent>
188
+ </a>
189
+ </Card>
190
+ <Card hoverable>
191
+ <CardHeader>
192
+ <CardTitle level={2}>中にコントロールがある場合</CardTitle>
193
+ <CardDescription>
194
+ asChild は使わず、本物のコントロールを内側に置きます
195
+ </CardDescription>
196
+ </CardHeader>
197
+ <CardFooter separated>
198
+ <Button variant="outline">詳細</Button>
199
+ </CardFooter>
200
+ </Card>
201
+ </ResponsiveGrid>
202
+ </Flex>
203
+
163
204
  <Flex direction="col" gap="sm">
164
205
  <Text as="div" weight="medium">
165
206
  Accent · accentPlacement=&quot;edge&quot; (既定 · 先頭エッジのレール)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "27.11.0",
4
- "godxUiMcp": "27.11.0",
3
+ "version": "27.12.0",
4
+ "godxUiMcp": "27.12.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -498,15 +498,12 @@ const RULES = [
498
498
  id: "icon-button-needs-name",
499
499
  severity: "warn",
500
500
  spansElement: true,
501
- // An icon-only Button (size="icon") with no author-supplied accessible name. A combobox/icon
502
- // button's name is computed from author (aria-label / aria-labelledby / title), not glyph content.
503
- test: new RegExp(
504
- `<Button\\b(?=${ATTRS}\\bsize=["']icon["'])(?!${ATTRS}\\b(?:aria-label|aria-labelledby|title)=)${ATTRS}>`,
505
- "g",
506
- ),
507
- standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2",
501
+ // A `<Button size="icon">` with no accessible name AT ALL — neither from the author
502
+ // (aria-label / aria-labelledby / title) nor from its CONTENT. See `iconButtonMatches`.
503
+ matches: iconButtonMatches,
504
+ standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2 · Accessible Name Computation 1.2",
508
505
  message:
509
- "Icon-only <Button size=\"icon\"> needs an accessible name — add aria-label={t('…')}. The icon is decorative (aria-hidden); the name comes from the author, not the glyph.",
506
+ "Icon-only <Button size=\"icon\"> has no accessible name — neither aria-label={t('…')} nor any content that names it. A visually-hidden child names it too: <VisuallyHidden>{t('…')}</VisuallyHidden> (or className=\"sr-only\") beside the aria-hidden glyph. A glyph alone, or text inside an aria-hidden subtree, names nothing.",
510
507
  },
511
508
  {
512
509
  id: "img-needs-alt",
@@ -1074,6 +1071,135 @@ function* lucideGlyphMatches(source) {
1074
1071
  }
1075
1072
  }
1076
1073
 
1074
+ /**
1075
+ * Is this subtree hidden from the accessibility tree? `aria-hidden` with anything but a literal
1076
+ * `false` — an author who writes `aria-hidden={busy}` is saying the subtree MAY be hidden, and a
1077
+ * name that may not be there is not a name.
1078
+ */
1079
+ const ariaHiddenTag = (openTag) =>
1080
+ !/\baria-hidden\s*=\s*(?:\{\s*false\s*\}|["']false["'])/.test(openTag) &&
1081
+ /\baria-hidden(?=[\s=/>])/.test(openTag);
1082
+
1083
+ /** The three attributes accname takes from the AUTHOR, on one opening tag. */
1084
+ const authoredNameTag = (openTag) => /\b(?:aria-label|aria-labelledby|title)\s*=/.test(openTag);
1085
+
1086
+ /** Where the element whose opening tag ended at `from` closes, counting nesting of the same tag. */
1087
+ function closingTagRange(source, tagName, from) {
1088
+ const tag = new RegExp(`<(/?)${tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[\\s/>])`, "g");
1089
+ tag.lastIndex = from;
1090
+ let depth = 0;
1091
+ for (let m; (m = tag.exec(source));) {
1092
+ if (m[1]) {
1093
+ if (depth === 0) {
1094
+ const gt = source.indexOf(">", m.index);
1095
+ return { start: m.index, end: gt < 0 ? source.length : gt + 1 };
1096
+ }
1097
+ depth -= 1;
1098
+ continue;
1099
+ }
1100
+ const end = jsxOpeningEnd(source, m.index);
1101
+ if (!/\/\s*$/.test(source.slice(m.index, end))) depth += 1;
1102
+ tag.lastIndex = end + 1;
1103
+ }
1104
+ return null;
1105
+ }
1106
+
1107
+ /**
1108
+ * "Name from content" (Accessible Name and Description Computation §2F) over JSX SOURCE: does
1109
+ * anything in these children reach the accessibility tree as text?
1110
+ *
1111
+ * - an `aria-hidden` subtree contributes NOTHING, however much text it holds;
1112
+ * - a visually-hidden child does — `sr-only` (and `VisuallyHidden`, which renders `span.sr-only`)
1113
+ * clips the box, it does not `display:none` the node, so the text is still named;
1114
+ * - a non-hidden child carrying its own `aria-label`/`title` contributes that name (`asChild`);
1115
+ * - an `{expression}` renders text, EXCEPT when it holds JSX — then what renders is those
1116
+ * elements, and `open ? <ChevronUp /> : <ChevronDown />` is still two unnamed glyphs.
1117
+ *
1118
+ * Limits, both deliberate: a bare `{icon}` identifier is read as text (source alone cannot say
1119
+ * what it holds, and guessing "glyph" would re-create the false positive this replaces), and
1120
+ * visual hiding is only recognised as `aria-hidden` — a `hidden`/`display:none` class is not read.
1121
+ */
1122
+ function contributesName(source, textCounts = true) {
1123
+ let i = 0;
1124
+ while (i < source.length) {
1125
+ const char = source[i];
1126
+ if (char === "<") {
1127
+ if (source[i + 1] === "/") {
1128
+ const gt = source.indexOf(">", i);
1129
+ i = gt < 0 ? source.length : gt + 1;
1130
+ continue;
1131
+ }
1132
+ const tagName = /^<([A-Za-z][\w.:-]*)/.exec(source.slice(i))?.[1];
1133
+ if (!tagName) {
1134
+ i += 1;
1135
+ continue;
1136
+ }
1137
+ const end = jsxOpeningEnd(source, i);
1138
+ const openTag = source.slice(i, end + 1);
1139
+ const hidden = ariaHiddenTag(openTag);
1140
+ if (!hidden && authoredNameTag(openTag)) return true;
1141
+ if (/\/\s*>$/.test(openTag)) {
1142
+ i = end + 1;
1143
+ continue;
1144
+ }
1145
+ const close = closingTagRange(source, tagName, end + 1);
1146
+ const childrenEnd = close ? close.start : source.length;
1147
+ if (!hidden && contributesName(source.slice(end + 1, childrenEnd))) return true;
1148
+ i = close ? close.end : source.length;
1149
+ continue;
1150
+ }
1151
+ if (char === "{") {
1152
+ const close = matchBracket(source, i);
1153
+ const expr = source.slice(i + 1, close < 0 ? source.length : close);
1154
+ if (/<[A-Za-z]/.test(expr)) {
1155
+ if (contributesName(expr, false)) return true;
1156
+ } else if (
1157
+ /\S/.test(expr.replace(/(["'`])(?:\\.|(?!\1)[\s\S])*?\1/g, (s) => s.slice(1, -1)))
1158
+ ) {
1159
+ // `{t("…")}` / `{label}` name it; prettier's `{" "}` does not.
1160
+ return true;
1161
+ }
1162
+ i = close < 0 ? source.length : close + 1;
1163
+ continue;
1164
+ }
1165
+ // Inside an expression the scaffolding (`cond ? … : …`, `.map(…)`) is not rendered text, but a
1166
+ // string literal in it is.
1167
+ if (!textCounts && (char === '"' || char === "'" || char === "`")) {
1168
+ const end = endOfString(source, i);
1169
+ if (/\S/.test(source.slice(i + 1, end))) return true;
1170
+ i = end + 1;
1171
+ continue;
1172
+ }
1173
+ if (textCounts && char.trim()) return true;
1174
+ i += 1;
1175
+ }
1176
+ return false;
1177
+ }
1178
+
1179
+ /**
1180
+ * An icon-only `<Button size="icon">` that NOTHING names (gh#739).
1181
+ *
1182
+ * The rule was a single regex over the OPENING TAG, so the most standard naming of an icon button
1183
+ * — an `aria-hidden` glyph beside visually-hidden text — was reported as unnamed: 5/5 findings in
1184
+ * one consumer repo were that shape. Following the message literally adds an `aria-label` on TOP
1185
+ * of a name, and two name sources drift apart the first time one i18n key is edited. So read the
1186
+ * children the way accname does, via `contributesName`.
1187
+ */
1188
+ function* iconButtonMatches(source) {
1189
+ for (const m of source.matchAll(/<Button(?=[\s/>])/g)) {
1190
+ const end = jsxOpeningEnd(source, m.index);
1191
+ if (end >= source.length) continue;
1192
+ const openTag = source.slice(m.index, end + 1);
1193
+ if (!/\bsize\s*=\s*["']icon["']/.test(openTag)) continue;
1194
+ if (authoredNameTag(openTag)) continue;
1195
+ if (!/\/\s*>$/.test(openTag)) {
1196
+ const close = closingTagRange(source, "Button", end + 1);
1197
+ if (contributesName(source.slice(end + 1, close ? close.start : source.length))) continue;
1198
+ }
1199
+ yield { 0: openTag, index: m.index };
1200
+ }
1201
+ }
1202
+
1077
1203
  /** Match real JSX text after a balanced opening tag, including props with comparisons. */
1078
1204
  function* currencyMatches(source) {
1079
1205
  for (const opening of source.matchAll(/<(?:[A-Za-z][\w.:]*\b|(?=>))/g)) {