melta-app 0.4.3 → 0.5.1

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/README.md CHANGED
@@ -7,7 +7,9 @@
7
7
  - **Showcase / Live Catalog**: https://app.melta.tsubotax.com (Live Catalog は実 RN コンポーネントの web export。HTML 再現デモではない)
8
8
  - **Web 版 showcase**: https://melta.tsubotax.com
9
9
 
10
- D2I アプリが最初の adopter / dogfood。最初の実需は「活動記録をサマリーカードに焼いて外部シェア」機能。
10
+ **現在の adopter 1 つ**(非公開プロジェクトのモバイルアプリ)。dogfood はそこで回している。
11
+ D2I は当初想定していた最初の adopter だが、mobile 側はまだ melta-app を導入していない
12
+ (D2I の web 側が使っているのは melta-ui)。
11
13
 
12
14
  ## Install
13
15
 
@@ -74,10 +76,33 @@ const theme = defineTheme({
74
76
 
75
77
  > 現状 `color.primary` / `text-accent` / `elevation` / status の light 値は、単一 dark のテーマでも**必須のまま**。省略できるのは `color.semantic` の mode だけで、他の軸を「持たない」と宣言する仕組みは後続で入れる。
76
78
 
79
+ ### 押せるカード(`variant="action"` / `"link"`)
80
+
81
+ **カード面そのものは操作要素ではない。** 面を押せるのはポインタ利用者向けの近道で、
82
+ キーボードとスクリーンリーダーからの到達手段は**カード内の操作要素**が担う。
83
+ そのため `primaryAction` が必須になっている。
84
+
85
+ ```tsx
86
+ <Card
87
+ variant="action"
88
+ onPress={openLog}
89
+ primaryAction={<Button label="製作ログを見る" onPress={openLog} />}
90
+ >
91
+ <Text variant="lg" role="heading">工房 #12</Text>
92
+ </Card>
93
+ ```
94
+
95
+ - `primaryAction` は面の `onPress` と**同じ操作**を指す(別の遷移先にしない)
96
+ - ラベルは `primaryAction` 側に付ける。カードは名前を持たない(`role="article"` の領域)
97
+ - カード内に操作要素が複数あってよい。`primaryAction` に置くのは**主アクション**(面を押したときの遷移先と一致するもの)
98
+ - 面に `accessibilityRole="button"` は付かない。`Button` を内包しても DOM が壊れない
99
+
100
+ 0.4.x からの移行は [CHANGELOG](./CHANGELOG.md#050--2026-07-29) を参照。
101
+
77
102
  ## 設計の核
78
103
 
79
104
  - **契約は共有、実装は各最適**: tokens / 禁止ルール / component 契約は `melta-contracts`(JSON)が SSOT。melta-app に token は持たない(二重化を物理防止)。
80
- - **公開 DS の純度を守る**: 汎用 UI プリミティブだけを置く。D2I 固有 UI(アプリ固有の画面・機能)は **D2I 側**に置き、ここには混ぜない。
105
+ - **公開 DS の純度を守る**: 汎用 UI プリミティブだけを置く。アプリ固有の UI(特定サービスの画面・機能・語彙)は**各アプリ側**に置き、ここには混ぜない。
81
106
  - **依存最小**: 初期は RN `StyleSheet` 固定(nativewind / unistyles 等の runtime styling lib を入れない)。Storybook RN も使わず自前カタログ。
82
107
 
83
108
  ## AI-Ready
@@ -11,26 +11,56 @@
11
11
  * action/link は pressed 時 elevation.sm→md(§2 hover→pressed mapping)。
12
12
  * focus-within は RN 非サポートとして Phase1 明示 drop(§2)。
13
13
  * - 型で action/link は onPress 必須を強制(§4 の DU 精神。無言で非対話に落ちる事故を防ぐ)。
14
+ * - **カード面自体は操作要素にしない**(contract 2.1.0 の a11y.required)。面の Pressable は
15
+ * `accessibilityRole` を名乗らず `accessible={false}`。理由は2つ:
16
+ * 1. 面に button role を付けると、中に Button を置いたとき web で `<button>` の入れ子になり
17
+ * hydration error になる(production では minified React error #418 に潰れて原因が読めない)
18
+ * 2. 面はキーボードでもスクリーンリーダーでも押せない。面のタップは**ポインタ向けの近道**で、
19
+ * 他の入力手段からの到達は内包する `primaryAction` が担う
20
+ * そのため action/link は `primaryAction` を必須にする。書き忘れると「ポインタでしか押せない
21
+ * カード」が静かに出来上がるので、型で止める。
22
+ * - `role="article"` は contract の a11y.role をそのまま表現したもの(RN の W3C 準拠 prop。
23
+ * react-native-web では実際に `<article>` 要素になる)。accessibilityRole 系とは別系統。
24
+ * - **focus ring は Card では描かない**。フォーカスを受けるのは `primaryAction` 側で、
25
+ * melta の Button は自前の ring を持つ。カードにも出すと1回のフォーカスで ring が二重に描かれる。
26
+ * contract の focus-within は RN 非サポートとして Phase1 で明示 drop 済み(app recipe に記録あり)で、
27
+ * web だけ動く実装を残すとプラットフォーム非対称になるため、実装ごと持たない。
28
+ * - `accessibilityLabel` は 0.5.0 で削除した。面が `accessible={false}` なので native では
29
+ * 原理的に読まれず、web だけ `aria-label` として効く非対称な prop だった。
30
+ * 名前は操作要素が持つべきなので `primaryAction` 側に付ける。
14
31
  * - Card は Image/Metric に依存しない(§1)。media/header/footer/children に呼び出し側が差し込む。
15
32
  * 呼び出し側(例: D2I)は用途に応じて slot に Image+Metric を compose する。
16
33
  * - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
17
34
  */
18
35
 
36
+ import { useEffect } from "react";
19
37
  import { Pressable, View } from "react-native";
20
38
  import { useTheme } from "../theme/index.js";
21
- import { useFocusRing, FocusRing } from "../primitives/_internal/focus-ring.js";
22
39
  import { CONTRACTS } from "../contracts/contract-types.js";
40
+ import { isDev } from "../theme/define-theme.js";
23
41
  import { CARD_INTERACTIVE, resolveCardShape, resolveCardBodyStyle } from "./card.styles.js";
42
+ import { validateCardProps } from "./card.validate.js";
24
43
 
25
- // action/link(インタラクティブ)は onPress 必須、basic/media は onPress を取らない(§4 DU 精神)。
44
+ /**
45
+ * 同じ問題を毎レンダー報告しないためのラッチ。
46
+ *
47
+ * **報告は effect の中で行う**(render 中ではない)。render 中だと、React が破棄する
48
+ * speculative render や StrictMode の二重 render でもラッチが消費され、実際に commit された
49
+ * 違反が報告されなくなりうる。検査の中身は card.validate.ts の純関数側でテストする。
50
+ */
26
51
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
52
+ const reported = new Set();
53
+
54
+ // action/link(インタラクティブ)は onPress と primaryAction が必須、
55
+ // basic/media はどちらも取らない(§4 DU 精神)。
56
+
27
57
  export function Card({
28
58
  variant = "basic",
29
59
  header,
30
60
  footer,
31
61
  media,
32
62
  onPress,
33
- accessibilityLabel,
63
+ primaryAction,
34
64
  style,
35
65
  testID,
36
66
  children
@@ -39,18 +69,26 @@ export function Card({
39
69
  theme,
40
70
  mode
41
71
  } = useTheme();
42
- const {
43
- focused,
44
- focusHandlers
45
- } = useFocusRing();
46
72
  const interactive = CARD_INTERACTIVE[variant] && onPress != null;
73
+ useEffect(() => {
74
+ if (!isDev) return;
75
+ for (const problem of validateCardProps({
76
+ variant,
77
+ onPress,
78
+ primaryAction
79
+ })) {
80
+ if (reported.has(problem)) continue;
81
+ reported.add(problem);
82
+ console.error(`melta: ${problem}`);
83
+ }
84
+ }, [variant, onPress, primaryAction]);
47
85
  const isMedia = variant === "media";
48
86
  const inner = /*#__PURE__*/_jsxs(_Fragment, {
49
87
  children: [media != null && /*#__PURE__*/_jsx(View, {
50
88
  children: media
51
89
  }), /*#__PURE__*/_jsxs(View, {
52
90
  style: resolveCardBodyStyle(theme, variant),
53
- children: [header, children, footer]
91
+ children: [header, children, footer, primaryAction]
54
92
  })]
55
93
  });
56
94
 
@@ -65,26 +103,27 @@ export function Card({
65
103
  }) : inner;
66
104
  if (!interactive) {
67
105
  return /*#__PURE__*/_jsx(View, {
106
+ role: "article",
68
107
  testID: testID,
69
108
  style: [resolveCardShape(theme, mode, variant), style],
70
109
  children: content
71
110
  });
72
111
  }
73
112
 
74
- // action/link: pressed 時 elevation.sm→mdfocus ring overlay。
75
- return /*#__PURE__*/_jsxs(Pressable, {
76
- ...focusHandlers,
77
- onPress: onPress,
78
- accessibilityRole: "button",
79
- accessibilityLabel: accessibilityLabel,
113
+ // action/link: pressed 時 elevation.sm→mdfocus 表示は primaryAction 側が持つ)。
114
+ return /*#__PURE__*/_jsx(Pressable, {
115
+ role: "article",
116
+ onPress: onPress
117
+ // 面は「操作要素」ではなく article(contract 2.1.0)。accessibilityRole は名乗らない。
118
+ // accessible={false} で a11y ツリーから子を畳まないことで、内包する primaryAction が
119
+ // 独立した操作要素として読み上げられる(既定の Pressable は子を畳んでしまう)。
120
+ ,
121
+ accessible: false,
80
122
  testID: testID,
81
123
  style: ({
82
124
  pressed
83
125
  }) => [resolveCardShape(theme, mode, variant, pressed), style],
84
- children: [content, /*#__PURE__*/_jsx(FocusRing, {
85
- visible: focused,
86
- radius: theme.radius.lg
87
- })]
126
+ children: content
88
127
  });
89
128
  }
90
129
 
@@ -1 +1 @@
1
- {"version":3,"names":["Pressable","View","useTheme","useFocusRing","FocusRing","CONTRACTS","CARD_INTERACTIVE","resolveCardShape","resolveCardBodyStyle","jsx","_jsx","jsxs","_jsxs","Fragment","_Fragment","Card","variant","header","footer","media","onPress","accessibilityLabel","style","testID","children","theme","mode","focused","focusHandlers","interactive","isMedia","inner","content","borderRadius","radius","lg","overflow","accessibilityRole","pressed","visible","__contract","card"],"sourceRoot":"../../../src","sources":["components/Card.tsx"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA,SAASA,SAAS,EAAEC,IAAI,QAAwC,cAAc;AAC9E,SAASC,QAAQ,QAAQ,mBAAU;AACnC,SAASC,YAAY,EAAEC,SAAS,QAAQ,uCAAoC;AAC5E,SAASC,SAAS,QAAQ,gCAA6B;AACvD,SAASC,gBAAgB,EAAEC,gBAAgB,EAAEC,oBAAoB,QAAQ,kBAAe;;AAaxF;AAAA,SAAAC,GAAA,IAAAC,IAAA,EAAAC,IAAA,IAAAC,KAAA,EAAAC,QAAA,IAAAC,SAAA;AAOA,OAAO,SAASC,IAAIA,CAAC;EACnBC,OAAO,GAAG,OAAO;EACjBC,MAAM;EACNC,MAAM;EACNC,KAAK;EACLC,OAAO;EACPC,kBAAkB;EAClBC,KAAK;EACLC,MAAM;EACNC;AACS,CAAC,EAAE;EACZ,MAAM;IAAEC,KAAK;IAAEC;EAAK,CAAC,GAAGxB,QAAQ,CAAC,CAAC;EAClC,MAAM;IAAEyB,OAAO;IAAEC;EAAc,CAAC,GAAGzB,YAAY,CAAC,CAAC;EACjD,MAAM0B,WAAW,GAAGvB,gBAAgB,CAACU,OAAO,CAAC,IAAII,OAAO,IAAI,IAAI;EAChE,MAAMU,OAAO,GAAGd,OAAO,KAAK,OAAO;EAEnC,MAAMe,KAAK,gBACTnB,KAAA,CAAAE,SAAA;IAAAU,QAAA,GACGL,KAAK,IAAI,IAAI,iBAAIT,IAAA,CAACT,IAAI;MAAAuB,QAAA,EAAEL;IAAK,CAAO,CAAC,eACtCP,KAAA,CAACX,IAAI;MAACqB,KAAK,EAAEd,oBAAoB,CAACiB,KAAK,EAAET,OAAO,CAAE;MAAAQ,QAAA,GAC/CP,MAAM,EACNO,QAAQ,EACRN,MAAM;IAAA,CACH,CAAC;EAAA,CACP,CACH;;EAED;EACA;EACA,MAAMc,OAAO,GAAGF,OAAO,gBACrBpB,IAAA,CAACT,IAAI;IAACqB,KAAK,EAAE;MAAEW,YAAY,EAAER,KAAK,CAACS,MAAM,CAACC,EAAE;MAAEC,QAAQ,EAAE;IAAS,CAAE;IAAAZ,QAAA,EAAEO;EAAK,CAAO,CAAC,GAElFA,KACD;EAED,IAAI,CAACF,WAAW,EAAE;IAChB,oBACEnB,IAAA,CAACT,IAAI;MAACsB,MAAM,EAAEA,MAAO;MAACD,KAAK,EAAE,CAACf,gBAAgB,CAACkB,KAAK,EAAEC,IAAI,EAAEV,OAAO,CAAC,EAAEM,KAAK,CAAE;MAAAE,QAAA,EAC1EQ;IAAO,CACJ,CAAC;EAEX;;EAEA;EACA,oBACEpB,KAAA,CAACZ,SAAS;IAAA,GACJ4B,aAAa;IACjBR,OAAO,EAAEA,OAAQ;IACjBiB,iBAAiB,EAAC,QAAQ;IAC1BhB,kBAAkB,EAAEA,kBAAmB;IACvCE,MAAM,EAAEA,MAAO;IACfD,KAAK,EAAEA,CAAC;MAAEgB;IAAQ,CAAC,KAAK,CAAC/B,gBAAgB,CAACkB,KAAK,EAAEC,IAAI,EAAEV,OAAO,EAAEsB,OAAO,CAAC,EAAEhB,KAAK,CAAE;IAAAE,QAAA,GAEhFQ,OAAO,eACRtB,IAAA,CAACN,SAAS;MAACmC,OAAO,EAAEZ,OAAQ;MAACO,MAAM,EAAET,KAAK,CAACS,MAAM,CAACC;IAAG,CAAE,CAAC;EAAA,CAC/C,CAAC;AAEhB;;AAEA;AACApB,IAAI,CAACyB,UAAU,GAAGnC,SAAS,CAACoC,IAAI","ignoreList":[]}
1
+ {"version":3,"names":["useEffect","Pressable","View","useTheme","CONTRACTS","isDev","CARD_INTERACTIVE","resolveCardShape","resolveCardBodyStyle","validateCardProps","jsx","_jsx","jsxs","_jsxs","Fragment","_Fragment","reported","Set","Card","variant","header","footer","media","onPress","primaryAction","style","testID","children","theme","mode","interactive","problem","has","add","console","error","isMedia","inner","content","borderRadius","radius","lg","overflow","role","accessible","pressed","__contract","card"],"sourceRoot":"../../../src","sources":["components/Card.tsx"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,SAASA,SAAS,QAA2C,OAAO;AACpE,SAASC,SAAS,EAAEC,IAAI,QAAwC,cAAc;AAC9E,SAASC,QAAQ,QAAQ,mBAAU;AACnC,SAASC,SAAS,QAAQ,gCAA6B;AACvD,SAASC,KAAK,QAAQ,0BAAuB;AAC7C,SAASC,gBAAgB,EAAEC,gBAAgB,EAAEC,oBAAoB,QAAQ,kBAAe;AACxF,SAASC,iBAAiB,QAAQ,oBAAiB;;AAEnD;AACA;AACA;AACA;AACA;AACA;AACA;AANA,SAAAC,GAAA,IAAAC,IAAA,EAAAC,IAAA,IAAAC,KAAA,EAAAC,QAAA,IAAAC,SAAA;AAOA,MAAMC,QAAQ,GAAG,IAAIC,GAAG,CAAS,CAAC;;AAYlC;AACA;;AAgBA,OAAO,SAASC,IAAIA,CAAC;EACnBC,OAAO,GAAG,OAAO;EACjBC,MAAM;EACNC,MAAM;EACNC,KAAK;EACLC,OAAO;EACPC,aAAa;EACbC,KAAK;EACLC,MAAM;EACNC;AACS,CAAC,EAAE;EACZ,MAAM;IAAEC,KAAK;IAAEC;EAAK,CAAC,GAAG1B,QAAQ,CAAC,CAAC;EAClC,MAAM2B,WAAW,GAAGxB,gBAAgB,CAACa,OAAO,CAAC,IAAII,OAAO,IAAI,IAAI;EAEhEvB,SAAS,CAAC,MAAM;IACd,IAAI,CAACK,KAAK,EAAE;IACZ,KAAK,MAAM0B,OAAO,IAAItB,iBAAiB,CAAC;MAAEU,OAAO;MAAEI,OAAO;MAAEC;IAAc,CAAC,CAAC,EAAE;MAC5E,IAAIR,QAAQ,CAACgB,GAAG,CAACD,OAAO,CAAC,EAAE;MAC3Bf,QAAQ,CAACiB,GAAG,CAACF,OAAO,CAAC;MACrBG,OAAO,CAACC,KAAK,CAAC,UAAUJ,OAAO,EAAE,CAAC;IACpC;EACF,CAAC,EAAE,CAACZ,OAAO,EAAEI,OAAO,EAAEC,aAAa,CAAC,CAAC;EACrC,MAAMY,OAAO,GAAGjB,OAAO,KAAK,OAAO;EAEnC,MAAMkB,KAAK,gBACTxB,KAAA,CAAAE,SAAA;IAAAY,QAAA,GACGL,KAAK,IAAI,IAAI,iBAAIX,IAAA,CAACT,IAAI;MAAAyB,QAAA,EAAEL;IAAK,CAAO,CAAC,eACtCT,KAAA,CAACX,IAAI;MAACuB,KAAK,EAAEjB,oBAAoB,CAACoB,KAAK,EAAET,OAAO,CAAE;MAAAQ,QAAA,GAC/CP,MAAM,EACNO,QAAQ,EACRN,MAAM,EACNG,aAAa;IAAA,CACV,CAAC;EAAA,CACP,CACH;;EAED;EACA;EACA,MAAMc,OAAO,GAAGF,OAAO,gBACrBzB,IAAA,CAACT,IAAI;IAACuB,KAAK,EAAE;MAAEc,YAAY,EAAEX,KAAK,CAACY,MAAM,CAACC,EAAE;MAAEC,QAAQ,EAAE;IAAS,CAAE;IAAAf,QAAA,EAAEU;EAAK,CAAO,CAAC,GAElFA,KACD;EAED,IAAI,CAACP,WAAW,EAAE;IAChB,oBACEnB,IAAA,CAACT,IAAI;MAACyC,IAAI,EAAC,SAAS;MAACjB,MAAM,EAAEA,MAAO;MAACD,KAAK,EAAE,CAAClB,gBAAgB,CAACqB,KAAK,EAAEC,IAAI,EAAEV,OAAO,CAAC,EAAEM,KAAK,CAAE;MAAAE,QAAA,EACzFW;IAAO,CACJ,CAAC;EAEX;;EAEA;EACA,oBACE3B,IAAA,CAACV,SAAS;IACR0C,IAAI,EAAC,SAAS;IACdpB,OAAO,EAAEA;IACT;IACA;IACA;IAAA;IACAqB,UAAU,EAAE,KAAM;IAClBlB,MAAM,EAAEA,MAAO;IACfD,KAAK,EAAEA,CAAC;MAAEoB;IAAQ,CAAC,KAAK,CAACtC,gBAAgB,CAACqB,KAAK,EAAEC,IAAI,EAAEV,OAAO,EAAE0B,OAAO,CAAC,EAAEpB,KAAK,CAAE;IAAAE,QAAA,EAEhFW;EAAO,CACC,CAAC;AAEhB;;AAEA;AACApB,IAAI,CAAC4B,UAAU,GAAG1C,SAAS,CAAC2C,IAAI","ignoreList":[]}
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
5
+ *
6
+ * **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
7
+ * JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
8
+ *
9
+ * react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
10
+ * 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
11
+ * (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
12
+ */
13
+
14
+ import { isValidElement } from "react";
15
+ import { CARD_INTERACTIVE } from "./card.styles.js";
16
+ /**
17
+ * 問題の一覧を返す(空配列 = 問題なし)。
18
+ *
19
+ * 検出できるのは「欠落」と「明らかに型が違う」ところまで。
20
+ * `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
21
+ * 判定できない(そこは契約の文と README の説明が担う)。
22
+ */
23
+ export function validateCardProps({
24
+ variant,
25
+ onPress,
26
+ primaryAction
27
+ }) {
28
+ // 未知の variant は CARD_INTERACTIVE で undefined になり、以降の分岐が
29
+ // 「非インタラクティブ」に倒れて静かに通ってしまう。型を持たない利用者の typo
30
+ //(variant="acton" 等)が押せないカードとして出荷されるので、最初に弾く。
31
+ //
32
+ // ⚠️ 文字列型の確認を hasOwnProperty より**先**に置くこと。プロパティキーは
33
+ // ToPropertyKey で文字列化されるため、
34
+ // - `new String("action")` や `{ toString: () => "action" }` が正規 variant として通る
35
+ // - `Object.create(null)` は文字列化できず **TypeError で検査自体が落ちる**
36
+ // (dev の防御が防御にならない)
37
+ // いずれも実測で再現済み(Codex レビュー3周目)。
38
+ if (typeof variant !== "string") {
39
+ return [`Card の variant は文字列で渡す(受け取ったのは ${variant === null ? "null" : typeof variant})。`];
40
+ }
41
+ if (!Object.prototype.hasOwnProperty.call(CARD_INTERACTIVE, variant)) {
42
+ return [`Card の variant="${String(variant)}" は存在しない(` + `${Object.keys(CARD_INTERACTIVE).join(" / ")} のいずれか)。`];
43
+ }
44
+ if (!CARD_INTERACTIVE[variant]) {
45
+ const problems = [];
46
+ if (onPress != null) {
47
+ problems.push(`Card variant="${variant}" は onPress を取らない(押せるのは action / link)。`);
48
+ }
49
+ if (primaryAction != null) {
50
+ problems.push(`Card variant="${variant}" は primaryAction を取らない(押せるのは action / link)。`);
51
+ }
52
+ return problems;
53
+ }
54
+ const problems = [];
55
+ if (onPress == null) {
56
+ problems.push(`Card variant="${variant}" は onPress が要る。渡さないと押せない見た目のまま非対話になる。`);
57
+ } else if (typeof onPress !== "function") {
58
+ problems.push(`Card variant="${variant}" の onPress が関数でない(${typeof onPress})。`);
59
+ }
60
+ if (primaryAction == null) {
61
+ problems.push(`Card variant="${variant}" は primaryAction が要る(contract 2.1.0)。` + "カード面は操作要素ではないので、これが無いとキーボード / スクリーンリーダーから" + "カードのアクションに到達できない。面の onPress と同じ操作を指す Button 等を渡すこと。");
62
+ } else if (! /*#__PURE__*/isValidElement(primaryAction)) {
63
+ problems.push(`Card variant="${variant}" の primaryAction が React 要素でない(${typeof primaryAction})。` + "文字列や真偽値では操作要素にならない。");
64
+ }
65
+ return problems;
66
+ }
67
+ //# sourceMappingURL=card.validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["isValidElement","CARD_INTERACTIVE","validateCardProps","variant","onPress","primaryAction","Object","prototype","hasOwnProperty","call","String","keys","join","problems","push"],"sourceRoot":"../../../src","sources":["components/card.validate.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,SAASA,cAAc,QAAQ,OAAO;AAEtC,SAASC,gBAAgB,QAAQ,kBAAe;AAQhD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,iBAAiBA,CAAC;EAChCC,OAAO;EACPC,OAAO;EACPC;AACsB,CAAC,EAAY;EACnC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,IAAI,OAAOF,OAAO,KAAK,QAAQ,EAAE;IAC/B,OAAO,CACL,kCAAkCA,OAAO,KAAK,IAAI,GAAG,MAAM,GAAG,OAAOA,OAAO,IAAI,CACjF;EACH;EACA,IAAI,CAACG,MAAM,CAACC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACR,gBAAgB,EAAEE,OAAO,CAAC,EAAE;IACpE,OAAO,CACL,mBAAmBO,MAAM,CAACP,OAAO,CAAC,WAAW,GAC3C,GAAGG,MAAM,CAACK,IAAI,CAACV,gBAAgB,CAAC,CAACW,IAAI,CAAC,KAAK,CAAC,UAAU,CACzD;EACH;EAEA,IAAI,CAACX,gBAAgB,CAACE,OAAO,CAAC,EAAE;IAC9B,MAAMU,QAAkB,GAAG,EAAE;IAC7B,IAAIT,OAAO,IAAI,IAAI,EAAE;MACnBS,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,yCAAyC,CAAC;IAClF;IACA,IAAIE,aAAa,IAAI,IAAI,EAAE;MACzBQ,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,+CAA+C,CAAC;IACxF;IACA,OAAOU,QAAQ;EACjB;EAEA,MAAMA,QAAkB,GAAG,EAAE;EAE7B,IAAIT,OAAO,IAAI,IAAI,EAAE;IACnBS,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,wCAC1B,CAAC;EACH,CAAC,MAAM,IAAI,OAAOC,OAAO,KAAK,UAAU,EAAE;IACxCS,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,sBAAsB,OAAOC,OAAO,IAAI,CAAC;EACjF;EAEA,IAAIC,aAAa,IAAI,IAAI,EAAE;IACzBQ,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,wCAAwC,GAC9D,2CAA2C,GAC3C,qDACJ,CAAC;EACH,CAAC,MAAM,IAAI,eAACH,cAAc,CAACK,aAAa,CAAC,EAAE;IACzCQ,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,mCAAmC,OAAOE,aAAa,IAAI,GACjF,qBACJ,CAAC;EACH;EAEA,OAAOQ,QAAQ;AACjB","ignoreList":[]}
@@ -16,7 +16,7 @@ export const CONTRACTS = {
16
16
  },
17
17
  button: {
18
18
  id: "button",
19
- version: "2.0.0",
19
+ version: "2.1.0",
20
20
  variants: ["contained", "outlined", "brand-outline", "neutral", "lighted", "danger", "subtle"],
21
21
  sizes: ["small", "medium", "large"],
22
22
  states: ["default", "hover", "focus", "disabled", "loading"]
@@ -30,7 +30,7 @@ export const CONTRACTS = {
30
30
  },
31
31
  card: {
32
32
  id: "card",
33
- version: "2.0.0",
33
+ version: "2.1.0",
34
34
  variants: ["basic", "media", "action", "link"],
35
35
  sizes: [],
36
36
  states: ["default", "hover", "focus-within"]
@@ -9,17 +9,33 @@
9
9
  * action/link は pressed 時 elevation.sm→md(§2 hover→pressed mapping)。
10
10
  * focus-within は RN 非サポートとして Phase1 明示 drop(§2)。
11
11
  * - 型で action/link は onPress 必須を強制(§4 の DU 精神。無言で非対話に落ちる事故を防ぐ)。
12
+ * - **カード面自体は操作要素にしない**(contract 2.1.0 の a11y.required)。面の Pressable は
13
+ * `accessibilityRole` を名乗らず `accessible={false}`。理由は2つ:
14
+ * 1. 面に button role を付けると、中に Button を置いたとき web で `<button>` の入れ子になり
15
+ * hydration error になる(production では minified React error #418 に潰れて原因が読めない)
16
+ * 2. 面はキーボードでもスクリーンリーダーでも押せない。面のタップは**ポインタ向けの近道**で、
17
+ * 他の入力手段からの到達は内包する `primaryAction` が担う
18
+ * そのため action/link は `primaryAction` を必須にする。書き忘れると「ポインタでしか押せない
19
+ * カード」が静かに出来上がるので、型で止める。
20
+ * - `role="article"` は contract の a11y.role をそのまま表現したもの(RN の W3C 準拠 prop。
21
+ * react-native-web では実際に `<article>` 要素になる)。accessibilityRole 系とは別系統。
22
+ * - **focus ring は Card では描かない**。フォーカスを受けるのは `primaryAction` 側で、
23
+ * melta の Button は自前の ring を持つ。カードにも出すと1回のフォーカスで ring が二重に描かれる。
24
+ * contract の focus-within は RN 非サポートとして Phase1 で明示 drop 済み(app recipe に記録あり)で、
25
+ * web だけ動く実装を残すとプラットフォーム非対称になるため、実装ごと持たない。
26
+ * - `accessibilityLabel` は 0.5.0 で削除した。面が `accessible={false}` なので native では
27
+ * 原理的に読まれず、web だけ `aria-label` として効く非対称な prop だった。
28
+ * 名前は操作要素が持つべきなので `primaryAction` 側に付ける。
12
29
  * - Card は Image/Metric に依存しない(§1)。media/header/footer/children に呼び出し側が差し込む。
13
30
  * 呼び出し側(例: D2I)は用途に応じて slot に Image+Metric を compose する。
14
31
  * - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
15
32
  */
16
- import type { ReactNode } from "react";
33
+ import { type ReactElement, type ReactNode } from "react";
17
34
  import { type StyleProp, type ViewStyle } from "react-native";
18
35
  interface CardBase {
19
36
  header?: ReactNode;
20
37
  footer?: ReactNode;
21
38
  media?: ReactNode;
22
- accessibilityLabel?: string;
23
39
  style?: StyleProp<ViewStyle>;
24
40
  testID?: string;
25
41
  /** body。 */
@@ -28,15 +44,22 @@ interface CardBase {
28
44
  type CardProps = CardBase & ({
29
45
  variant?: "basic" | "media";
30
46
  onPress?: never;
47
+ primaryAction?: never;
31
48
  } | {
32
49
  variant: "action" | "link";
33
50
  onPress: () => void;
51
+ /**
52
+ * 面のタップと同じ遷移先を持つ操作要素(Button 等)。footer 位置に描画される。
53
+ * 面は role を持たないので、キーボード / スクリーンリーダーからの唯一の到達手段になる。
54
+ * 内包する操作要素が他にもある場合、ここに置くのは**主アクション**(contract 2.1.0)。
55
+ */
56
+ primaryAction: ReactElement;
34
57
  });
35
- export declare function Card({ variant, header, footer, media, onPress, accessibilityLabel, style, testID, children, }: CardProps): import("react/jsx-runtime").JSX.Element;
58
+ export declare function Card({ variant, header, footer, media, onPress, primaryAction, style, testID, children, }: CardProps): import("react/jsx-runtime").JSX.Element;
36
59
  export declare namespace Card {
37
60
  var __contract: {
38
61
  readonly id: "card";
39
- readonly version: "2.0.0";
62
+ readonly version: "2.1.0";
40
63
  readonly variants: readonly ["basic", "media", "action", "link"];
41
64
  readonly sizes: readonly [];
42
65
  readonly states: readonly ["default", "hover", "focus-within"];
@@ -1 +1 @@
1
- {"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../../../src/components/Card.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAM/E,UAAU,QAAQ;IAChB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY;IACZ,QAAQ,EAAE,SAAS,CAAC;CACrB;AAGD,KAAK,SAAS,GAAG,QAAQ,GACvB,CACI;IAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,KAAK,CAAA;CAAE,GAChD;IAAE,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CACtD,CAAC;AAEJ,wBAAgB,IAAI,CAAC,EACnB,OAAiB,EACjB,MAAM,EACN,MAAM,EACN,KAAK,EACL,OAAO,EACP,kBAAkB,EAClB,KAAK,EACL,MAAM,EACN,QAAQ,GACT,EAAE,SAAS,2CA+CX;yBAzDe,IAAI"}
1
+ {"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../../../src/components/Card.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAa,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACrE,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAgB/E,UAAU,QAAQ;IAChB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY;IACZ,QAAQ,EAAE,SAAS,CAAC;CACrB;AAID,KAAK,SAAS,GAAG,QAAQ,GACvB,CACI;IAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GACvE;IACE,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;;OAIG;IACH,aAAa,EAAE,YAAY,CAAC;CAC7B,CACJ,CAAC;AAEJ,wBAAgB,IAAI,CAAC,EACnB,OAAiB,EACjB,MAAM,EACN,MAAM,EACN,KAAK,EACL,OAAO,EACP,aAAa,EACb,KAAK,EACL,MAAM,EACN,QAAQ,GACT,EAAE,SAAS,2CAyDX;yBAnEe,IAAI"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
3
+ *
4
+ * **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
5
+ * JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
6
+ *
7
+ * react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
8
+ * 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
9
+ * (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
10
+ */
11
+ import type { CardVariant } from "./card.styles";
12
+ export interface CardPropsForValidation {
13
+ variant: CardVariant;
14
+ onPress?: unknown;
15
+ primaryAction?: unknown;
16
+ }
17
+ /**
18
+ * 問題の一覧を返す(空配列 = 問題なし)。
19
+ *
20
+ * 検出できるのは「欠落」と「明らかに型が違う」ところまで。
21
+ * `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
22
+ * 判定できない(そこは契約の文と README の説明が担う)。
23
+ */
24
+ export declare function validateCardProps({ variant, onPress, primaryAction, }: CardPropsForValidation): string[];
25
+ //# sourceMappingURL=card.validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card.validate.d.ts","sourceRoot":"","sources":["../../../../src/components/card.validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,OAAO,EACP,aAAa,GACd,EAAE,sBAAsB,GAAG,MAAM,EAAE,CA0DnC"}
@@ -16,7 +16,7 @@ export declare const CONTRACTS: {
16
16
  };
17
17
  readonly button: {
18
18
  readonly id: "button";
19
- readonly version: "2.0.0";
19
+ readonly version: "2.1.0";
20
20
  readonly variants: readonly ["contained", "outlined", "brand-outline", "neutral", "lighted", "danger", "subtle"];
21
21
  readonly sizes: readonly ["small", "medium", "large"];
22
22
  readonly states: readonly ["default", "hover", "focus", "disabled", "loading"];
@@ -30,7 +30,7 @@ export declare const CONTRACTS: {
30
30
  };
31
31
  readonly card: {
32
32
  readonly id: "card";
33
- readonly version: "2.0.0";
33
+ readonly version: "2.1.0";
34
34
  readonly variants: readonly ["basic", "media", "action", "link"];
35
35
  readonly sizes: readonly [];
36
36
  readonly states: readonly ["default", "hover", "focus-within"];
@@ -38,7 +38,7 @@ export declare function Button(props: ButtonProps): import("react/jsx-runtime").
38
38
  export declare namespace Button {
39
39
  var __contract: {
40
40
  readonly id: "button";
41
- readonly version: "2.0.0";
41
+ readonly version: "2.1.0";
42
42
  readonly variants: readonly ["contained", "outlined", "brand-outline", "neutral", "lighted", "danger", "subtle"];
43
43
  readonly sizes: readonly ["small", "medium", "large"];
44
44
  readonly states: readonly ["default", "hover", "focus", "disabled", "loading"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melta-app",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "melta for APP — melta デザインシステムの React Native UI kit。melta-contracts(デザイン契約)を single source of truth に RN 実装でその契約を満たす。アプリ本体ではなくライブラリ。",
5
5
  "license": "MIT",
6
6
  "homepage": "https://app.melta.tsubotax.com",
@@ -74,7 +74,7 @@
74
74
  "@types/react": "~19.2.2",
75
75
  "eslint": "^10.4.1",
76
76
  "jest": "^29.7.0",
77
- "melta-contracts": "^0.4.3",
77
+ "melta-contracts": "^0.6.0",
78
78
  "react": "19.2.3",
79
79
  "react-native": "0.85.3",
80
80
  "react-native-builder-bob": "^0.40.13",
@@ -9,34 +9,70 @@
9
9
  * action/link は pressed 時 elevation.sm→md(§2 hover→pressed mapping)。
10
10
  * focus-within は RN 非サポートとして Phase1 明示 drop(§2)。
11
11
  * - 型で action/link は onPress 必須を強制(§4 の DU 精神。無言で非対話に落ちる事故を防ぐ)。
12
+ * - **カード面自体は操作要素にしない**(contract 2.1.0 の a11y.required)。面の Pressable は
13
+ * `accessibilityRole` を名乗らず `accessible={false}`。理由は2つ:
14
+ * 1. 面に button role を付けると、中に Button を置いたとき web で `<button>` の入れ子になり
15
+ * hydration error になる(production では minified React error #418 に潰れて原因が読めない)
16
+ * 2. 面はキーボードでもスクリーンリーダーでも押せない。面のタップは**ポインタ向けの近道**で、
17
+ * 他の入力手段からの到達は内包する `primaryAction` が担う
18
+ * そのため action/link は `primaryAction` を必須にする。書き忘れると「ポインタでしか押せない
19
+ * カード」が静かに出来上がるので、型で止める。
20
+ * - `role="article"` は contract の a11y.role をそのまま表現したもの(RN の W3C 準拠 prop。
21
+ * react-native-web では実際に `<article>` 要素になる)。accessibilityRole 系とは別系統。
22
+ * - **focus ring は Card では描かない**。フォーカスを受けるのは `primaryAction` 側で、
23
+ * melta の Button は自前の ring を持つ。カードにも出すと1回のフォーカスで ring が二重に描かれる。
24
+ * contract の focus-within は RN 非サポートとして Phase1 で明示 drop 済み(app recipe に記録あり)で、
25
+ * web だけ動く実装を残すとプラットフォーム非対称になるため、実装ごと持たない。
26
+ * - `accessibilityLabel` は 0.5.0 で削除した。面が `accessible={false}` なので native では
27
+ * 原理的に読まれず、web だけ `aria-label` として効く非対称な prop だった。
28
+ * 名前は操作要素が持つべきなので `primaryAction` 側に付ける。
12
29
  * - Card は Image/Metric に依存しない(§1)。media/header/footer/children に呼び出し側が差し込む。
13
30
  * 呼び出し側(例: D2I)は用途に応じて slot に Image+Metric を compose する。
14
31
  * - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
15
32
  */
16
33
 
17
- import type { ReactNode } from "react";
34
+ import { useEffect, type ReactElement, type ReactNode } from "react";
18
35
  import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
19
36
  import { useTheme } from "../theme";
20
- import { useFocusRing, FocusRing } from "../primitives/_internal/focus-ring";
21
37
  import { CONTRACTS } from "../contracts/contract-types";
38
+ import { isDev } from "../theme/define-theme";
22
39
  import { CARD_INTERACTIVE, resolveCardShape, resolveCardBodyStyle } from "./card.styles";
40
+ import { validateCardProps } from "./card.validate";
41
+
42
+ /**
43
+ * 同じ問題を毎レンダー報告しないためのラッチ。
44
+ *
45
+ * **報告は effect の中で行う**(render 中ではない)。render 中だと、React が破棄する
46
+ * speculative render や StrictMode の二重 render でもラッチが消費され、実際に commit された
47
+ * 違反が報告されなくなりうる。検査の中身は card.validate.ts の純関数側でテストする。
48
+ */
49
+ const reported = new Set<string>();
23
50
 
24
51
  interface CardBase {
25
52
  header?: ReactNode;
26
53
  footer?: ReactNode;
27
54
  media?: ReactNode;
28
- accessibilityLabel?: string;
29
55
  style?: StyleProp<ViewStyle>;
30
56
  testID?: string;
31
57
  /** body。 */
32
58
  children: ReactNode;
33
59
  }
34
60
 
35
- // action/link(インタラクティブ)は onPress 必須、basic/media onPress を取らない(§4 DU 精神)。
61
+ // action/link(インタラクティブ)は onPress primaryAction が必須、
62
+ // basic/media はどちらも取らない(§4 DU 精神)。
36
63
  type CardProps = CardBase &
37
64
  (
38
- | { variant?: "basic" | "media"; onPress?: never }
39
- | { variant: "action" | "link"; onPress: () => void }
65
+ | { variant?: "basic" | "media"; onPress?: never; primaryAction?: never }
66
+ | {
67
+ variant: "action" | "link";
68
+ onPress: () => void;
69
+ /**
70
+ * 面のタップと同じ遷移先を持つ操作要素(Button 等)。footer 位置に描画される。
71
+ * 面は role を持たないので、キーボード / スクリーンリーダーからの唯一の到達手段になる。
72
+ * 内包する操作要素が他にもある場合、ここに置くのは**主アクション**(contract 2.1.0)。
73
+ */
74
+ primaryAction: ReactElement;
75
+ }
40
76
  );
41
77
 
42
78
  export function Card({
@@ -45,14 +81,22 @@ export function Card({
45
81
  footer,
46
82
  media,
47
83
  onPress,
48
- accessibilityLabel,
84
+ primaryAction,
49
85
  style,
50
86
  testID,
51
87
  children,
52
88
  }: CardProps) {
53
89
  const { theme, mode } = useTheme();
54
- const { focused, focusHandlers } = useFocusRing();
55
90
  const interactive = CARD_INTERACTIVE[variant] && onPress != null;
91
+
92
+ useEffect(() => {
93
+ if (!isDev) return;
94
+ for (const problem of validateCardProps({ variant, onPress, primaryAction })) {
95
+ if (reported.has(problem)) continue;
96
+ reported.add(problem);
97
+ console.error(`melta: ${problem}`);
98
+ }
99
+ }, [variant, onPress, primaryAction]);
56
100
  const isMedia = variant === "media";
57
101
 
58
102
  const inner = (
@@ -62,6 +106,7 @@ export function Card({
62
106
  {header}
63
107
  {children}
64
108
  {footer}
109
+ {primaryAction}
65
110
  </View>
66
111
  </>
67
112
  );
@@ -76,24 +121,25 @@ export function Card({
76
121
 
77
122
  if (!interactive) {
78
123
  return (
79
- <View testID={testID} style={[resolveCardShape(theme, mode, variant), style]}>
124
+ <View role="article" testID={testID} style={[resolveCardShape(theme, mode, variant), style]}>
80
125
  {content}
81
126
  </View>
82
127
  );
83
128
  }
84
129
 
85
- // action/link: pressed 時 elevation.sm→mdfocus ring overlay。
130
+ // action/link: pressed 時 elevation.sm→mdfocus 表示は primaryAction 側が持つ)。
86
131
  return (
87
132
  <Pressable
88
- {...focusHandlers}
133
+ role="article"
89
134
  onPress={onPress}
90
- accessibilityRole="button"
91
- accessibilityLabel={accessibilityLabel}
135
+ // 面は「操作要素」ではなく article(contract 2.1.0)。accessibilityRole は名乗らない。
136
+ // accessible={false} で a11y ツリーから子を畳まないことで、内包する primaryAction が
137
+ // 独立した操作要素として読み上げられる(既定の Pressable は子を畳んでしまう)。
138
+ accessible={false}
92
139
  testID={testID}
93
140
  style={({ pressed }) => [resolveCardShape(theme, mode, variant, pressed), style]}
94
141
  >
95
142
  {content}
96
- <FocusRing visible={focused} radius={theme.radius.lg} />
97
143
  </Pressable>
98
144
  );
99
145
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
3
+ *
4
+ * **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
5
+ * JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
6
+ *
7
+ * react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
8
+ * 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
9
+ * (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
10
+ */
11
+
12
+ import { isValidElement } from "react";
13
+ import type { CardVariant } from "./card.styles";
14
+ import { CARD_INTERACTIVE } from "./card.styles";
15
+
16
+ export interface CardPropsForValidation {
17
+ variant: CardVariant;
18
+ onPress?: unknown;
19
+ primaryAction?: unknown;
20
+ }
21
+
22
+ /**
23
+ * 問題の一覧を返す(空配列 = 問題なし)。
24
+ *
25
+ * 検出できるのは「欠落」と「明らかに型が違う」ところまで。
26
+ * `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
27
+ * 判定できない(そこは契約の文と README の説明が担う)。
28
+ */
29
+ export function validateCardProps({
30
+ variant,
31
+ onPress,
32
+ primaryAction,
33
+ }: CardPropsForValidation): string[] {
34
+ // 未知の variant は CARD_INTERACTIVE で undefined になり、以降の分岐が
35
+ // 「非インタラクティブ」に倒れて静かに通ってしまう。型を持たない利用者の typo
36
+ //(variant="acton" 等)が押せないカードとして出荷されるので、最初に弾く。
37
+ //
38
+ // ⚠️ 文字列型の確認を hasOwnProperty より**先**に置くこと。プロパティキーは
39
+ // ToPropertyKey で文字列化されるため、
40
+ // - `new String("action")` や `{ toString: () => "action" }` が正規 variant として通る
41
+ // - `Object.create(null)` は文字列化できず **TypeError で検査自体が落ちる**
42
+ // (dev の防御が防御にならない)
43
+ // いずれも実測で再現済み(Codex レビュー3周目)。
44
+ if (typeof variant !== "string") {
45
+ return [
46
+ `Card の variant は文字列で渡す(受け取ったのは ${variant === null ? "null" : typeof variant})。`,
47
+ ];
48
+ }
49
+ if (!Object.prototype.hasOwnProperty.call(CARD_INTERACTIVE, variant)) {
50
+ return [
51
+ `Card の variant="${String(variant)}" は存在しない(` +
52
+ `${Object.keys(CARD_INTERACTIVE).join(" / ")} のいずれか)。`,
53
+ ];
54
+ }
55
+
56
+ if (!CARD_INTERACTIVE[variant]) {
57
+ const problems: string[] = [];
58
+ if (onPress != null) {
59
+ problems.push(`Card variant="${variant}" は onPress を取らない(押せるのは action / link)。`);
60
+ }
61
+ if (primaryAction != null) {
62
+ problems.push(`Card variant="${variant}" は primaryAction を取らない(押せるのは action / link)。`);
63
+ }
64
+ return problems;
65
+ }
66
+
67
+ const problems: string[] = [];
68
+
69
+ if (onPress == null) {
70
+ problems.push(
71
+ `Card variant="${variant}" は onPress が要る。渡さないと押せない見た目のまま非対話になる。`,
72
+ );
73
+ } else if (typeof onPress !== "function") {
74
+ problems.push(`Card variant="${variant}" の onPress が関数でない(${typeof onPress})。`);
75
+ }
76
+
77
+ if (primaryAction == null) {
78
+ problems.push(
79
+ `Card variant="${variant}" は primaryAction が要る(contract 2.1.0)。` +
80
+ "カード面は操作要素ではないので、これが無いとキーボード / スクリーンリーダーから" +
81
+ "カードのアクションに到達できない。面の onPress と同じ操作を指す Button 等を渡すこと。",
82
+ );
83
+ } else if (!isValidElement(primaryAction)) {
84
+ problems.push(
85
+ `Card variant="${variant}" の primaryAction が React 要素でない(${typeof primaryAction})。` +
86
+ "文字列や真偽値では操作要素にならない。",
87
+ );
88
+ }
89
+
90
+ return problems;
91
+ }
@@ -21,7 +21,7 @@ export const CONTRACTS = {
21
21
  },
22
22
  button: {
23
23
  id: "button",
24
- version: "2.0.0",
24
+ version: "2.1.0",
25
25
  variants: ["contained", "outlined", "brand-outline", "neutral", "lighted", "danger", "subtle"],
26
26
  sizes: ["small", "medium", "large"],
27
27
  states: ["default", "hover", "focus", "disabled", "loading"],
@@ -35,7 +35,7 @@ export const CONTRACTS = {
35
35
  },
36
36
  card: {
37
37
  id: "card",
38
- version: "2.0.0",
38
+ version: "2.1.0",
39
39
  variants: ["basic", "media", "action", "link"],
40
40
  sizes: [],
41
41
  states: ["default", "hover", "focus-within"],