@godxjp/ui 23.4.10 → 23.4.12

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.
@@ -1,9 +1,11 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useTranslation } from "../../i18n/use-translation.js";
2
4
  import { Check, CheckCircle2, Plane } from "lucide-react";
3
- const SR_PREFIX = {
4
- done: "Completed: ",
5
- current: "Current: ",
6
- pending: "Upcoming: "
5
+ const SR_PREFIX_KEY = {
6
+ done: "dataDisplay.timeline.statusDone",
7
+ current: "dataDisplay.timeline.statusCurrent",
8
+ pending: "dataDisplay.timeline.statusPending"
7
9
  };
8
10
  function resolveStatus(item) {
9
11
  if (item.status) {
@@ -15,6 +17,7 @@ function resolveStatus(item) {
15
17
  return "done";
16
18
  }
17
19
  function Timeline({ items, variant = "icon" }) {
20
+ const { t } = useTranslation();
18
21
  return /* @__PURE__ */ jsx("ol", { className: "ui-timeline", "data-variant": variant, children: items.map((item, index) => {
19
22
  const status = resolveStatus(item);
20
23
  const isCurrent = status === "current";
@@ -66,7 +69,7 @@ function Timeline({ items, variant = "icon" }) {
66
69
  /* @__PURE__ */ jsxs("div", { className: "ui-timeline-body", children: [
67
70
  /* @__PURE__ */ jsxs("div", { className: "ui-timeline-head", children: [
68
71
  /* @__PURE__ */ jsxs("span", { className: "ui-timeline-title", "data-current": isCurrent ? "true" : void 0, children: [
69
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: SR_PREFIX[status] }),
72
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: t(SR_PREFIX_KEY[status]) }),
70
73
  item.title
71
74
  ] }),
72
75
  item.time ? /* @__PURE__ */ jsx("span", { className: "ui-timeline-time", children: item.time }) : null
@@ -155,9 +155,21 @@ interface DropdownMenuItemPropsOwn {
155
155
  textValue?: string;
156
156
  /** Mượn thẻ của con thay vì dựng `<div>` riêng — chỗ để nhét một `<Link>` vào một mục menu. */
157
157
  asChild?: boolean;
158
+ /**
159
+ * `id` và mọi `data-*` ĐI XUỐNG phần tử gốc của item (gh#631).
160
+ *
161
+ * Trigger của chính cụm này đã nhận cách xử lý ấy ở 20.0.0, với đúng lý do CHANGELOG ghi:
162
+ * "trước đó bị nuốt, nên selector e2e của consumer rời ra trong im lặng". Phần item thì chưa,
163
+ * nên hai nửa của cùng một component hành xử ngược nhau.
164
+ *
165
+ * TypeScript không bắt được: JSX luôn cho qua mọi thuộc tính có gạch nối, nên `data-testid`
166
+ * biên dịch sạch và chỉ hỏng lúc chạy. Với `id` còn tệ hơn — nó là thứ `aria-activedescendant`
167
+ * trỏ vào, và nó biến mất không một lời.
168
+ */
169
+ id?: string;
158
170
  }
159
- type DropdownMenuItemProps = React.PropsWithChildren<DropdownMenuItemPropsOwn>;
160
- export declare function DropdownMenuItem({ children, className, inset, variant, disabled, onSelect, textValue, asChild, }: DropdownMenuItemProps): React.JSX.Element;
171
+ type DropdownMenuItemProps = React.PropsWithChildren<DropdownMenuItemPropsOwn> & Record<`data-${string}`, unknown>;
172
+ export declare function DropdownMenuItem({ children, className, inset, variant, disabled, onSelect, textValue, asChild, ...forwarded }: DropdownMenuItemProps): React.JSX.Element;
161
173
  interface DropdownMenuLabelPropsOwn {
162
174
  className?: string;
163
175
  inset?: boolean;
@@ -267,22 +267,30 @@ function DropdownMenuRadioGroup({
267
267
  value,
268
268
  onValueChange
269
269
  }) {
270
- return /* @__PURE__ */ jsx(
271
- MenuSection,
272
- {
273
- "data-slot": "dropdown-menu-radio-group",
274
- className,
275
- selectionMode: "single",
276
- disallowEmptySelection: true,
277
- selectedKeys: value == null ? [] : [value],
278
- onSelectionChange: (keys) => {
279
- if (keys === "all") return;
280
- const [first] = [...keys];
281
- if (first != null) onValueChange?.(String(first));
282
- },
283
- children
270
+ const selection = {
271
+ className,
272
+ selectionMode: "single",
273
+ disallowEmptySelection: true,
274
+ selectedKeys: value == null ? [] : [value],
275
+ onSelectionChange: (keys) => {
276
+ if (keys === "all") return;
277
+ const [first] = [...keys];
278
+ if (first != null) onValueChange?.(String(first));
284
279
  }
285
- );
280
+ };
281
+ const runs = [{ label: null, items: [] }];
282
+ for (const child of React.Children.toArray(children)) {
283
+ if (React.isValidElement(child) && child.type === DropdownMenuLabel) {
284
+ runs.push({ label: child, items: [] });
285
+ } else {
286
+ runs[runs.length - 1].items.push(child);
287
+ }
288
+ }
289
+ const sections = runs.filter((run) => run.label != null || run.items.length > 0);
290
+ return /* @__PURE__ */ jsx(Fragment, { children: sections.map((run, index) => /* @__PURE__ */ jsxs(MenuSection, { "data-slot": "dropdown-menu-radio-group", ...selection, children: [
291
+ run.label,
292
+ run.items
293
+ ] }, index)) });
286
294
  }
287
295
  function DropdownMenuSub({ children }) {
288
296
  const parts = React.Children.toArray(children).filter(React.isValidElement);
@@ -372,7 +380,8 @@ function DropdownMenuItem({
372
380
  disabled,
373
381
  onSelect,
374
382
  textValue,
375
- asChild
383
+ asChild,
384
+ ...forwarded
376
385
  }) {
377
386
  const tag = borrowedTag(children, asChild);
378
387
  return /* @__PURE__ */ jsx(
@@ -394,7 +403,10 @@ function DropdownMenuItem({
394
403
  "data-slot": "dropdown-menu-item",
395
404
  "data-inset": inset,
396
405
  "data-variant": variant,
397
- ...radixItemState(state)
406
+ ...radixItemState(state),
407
+ // LAST, so a consumer's own `id`/`data-*` wins over ours rather than being
408
+ // overwritten by the slot bookkeeping above (gh#631).
409
+ ...forwarded
398
410
  },
399
411
  content
400
412
  );
@@ -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": "23.4.10",
3
+ "version": "23.4.12",
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,
@@ -39,6 +39,17 @@
39
39
  "components": [
40
40
  "number-input"
41
41
  ]
42
+ },
43
+ {
44
+ "selector": ".ui-switch",
45
+ "via": ".ui-choice-input",
46
+ "source": "src/styles/control.css",
47
+ "targetMin": 24,
48
+ "components": [
49
+ "label",
50
+ "slider",
51
+ "switch"
52
+ ]
42
53
  }
43
54
  ]
44
55
  },
@@ -339,6 +339,11 @@
339
339
  "error": "failed",
340
340
  "abort": "stopped"
341
341
  }
342
+ },
343
+ "timeline": {
344
+ "statusDone": "Completed: ",
345
+ "statusCurrent": "Current: ",
346
+ "statusPending": "Upcoming: "
342
347
  }
343
348
  },
344
349
  "ui": {
@@ -332,6 +332,11 @@
332
332
  "error": "失敗",
333
333
  "abort": "中断"
334
334
  }
335
+ },
336
+ "timeline": {
337
+ "statusDone": "完了: ",
338
+ "statusCurrent": "現在: ",
339
+ "statusPending": "予定: "
335
340
  }
336
341
  },
337
342
  "ui": {
@@ -333,6 +333,11 @@
333
333
  "error": "thất bại",
334
334
  "abort": "đã dừng"
335
335
  }
336
+ },
337
+ "timeline": {
338
+ "statusDone": "Đã xong: ",
339
+ "statusCurrent": "Hiện tại: ",
340
+ "statusPending": "Sắp tới: "
336
341
  }
337
342
  },
338
343
  "ui": {
@@ -500,6 +500,13 @@
500
500
  transition-duration: 150ms;
501
501
  }
502
502
 
503
+ .ui-switch > .ui-choice-input {
504
+ inset-block-start: 50%;
505
+ inset-block-end: auto;
506
+ block-size: max(100%, var(--touch-target-min));
507
+ translate: 0 -50%;
508
+ }
509
+
503
510
  .ui-switch[data-state="checked"] {
504
511
  background: var(--switch-checked-background, hsl(var(--primary)));
505
512
  }
@@ -267,7 +267,7 @@ export default function Demo() {
267
267
  <CardTitle level={2}>tabular · 数字を桁で揃える</CardTitle>
268
268
  <CardDescription>
269
269
  件数のチップが縦に並ぶときは tabular を。プロポーショナル数字だと 1 が 0
270
- より狭いので、列で見たときに桁がずれてチップ幅も揺れます。既定はオフ — 語を載せる
270
+ より狭いので、列で見たときに桁がずれてチップ幅も揺れます。既定はオフ。語を載せる
271
271
  チップまで幅の広い数字を払う必要はありません。
272
272
  </CardDescription>
273
273
  </CardHeader>
@@ -128,7 +128,7 @@ export default function Demo() {
128
128
 
129
129
  <Flex direction="col" gap="sm">
130
130
  <Text as="div" weight="medium">
131
- hoverable · actions — Ant Design の hoverable と actions
131
+ hoverable · actions · Ant Design の hoverable と actions
132
132
  </Text>
133
133
  <ResponsiveGrid columns={{ sm: 1, md: 2 }}>
134
134
  {/*
@@ -210,7 +210,7 @@ export default function Demo() {
210
210
  <CardHeader>
211
211
  <CardTitle level={2}>width · パネルの測り方</CardTitle>
212
212
  <CardDescription>
213
- 既定の panel は --popover-width(18rem)。auto は中身に測らせます — 自前の幅を持つもの
213
+ 既定の panel は --popover-width(18rem)。auto は中身に測らせます。自前の幅を持つもの
214
214
  (Calendar、チャート、固定幅のプレビュー)はこれ以外に正解がありません。trigger
215
215
  はアンカーに合わせます。className に w-*
216
216
  を書くのは呼び出し側の定数で、どのテーマからも retune できません。flush が padding
@@ -166,7 +166,7 @@ export default function Demo() {
166
166
  </CardTitle>
167
167
  <CardDescription>
168
168
  トリガー文字は差し替えられます。1つの入力欄に対して1つの
169
- ChatSuggestion — 2種類を同時に見張るのではなく、用途ごとに別の欄にします。
169
+ ChatSuggestion。2種類を同時に見張るのではなく、用途ごとに別の欄にします。
170
170
  </CardDescription>
171
171
  </CardHeader>
172
172
  <CardContent>
@@ -89,7 +89,7 @@ export default function Demo() {
89
89
  <CardTitle level={2}>4 択 + 件数(スマホ幅では折り返す)</CardTitle>
90
90
  <CardDescription>
91
91
  一覧の上の状態フィルター。4 つが 1 行に収まらない幅では、トラックが 2 行目に折り返し
92
- ます — ラベルも件数も切り詰めません。`check:segmented-wrap` がこのカードを測ります。
92
+ ます。ラベルも件数も切り詰めません。`check:segmented-wrap` がこのカードを測ります。
93
93
  </CardDescription>
94
94
  </CardHeader>
95
95
  <CardContent>
@@ -112,7 +112,7 @@ export default function Demo() {
112
112
  <CardDescription>
113
113
  size は 3 段(sm / md / lg)。トラックの高さは --control-height そのもので、
114
114
  MobileShell のようにその変数をスコープする領域では自動的にタッチ段(44px)になります。
115
- vertical では 1 行ぶんが 1 コントロールの高さになります — 横 1 行のときだけトラック
115
+ vertical では 1 行ぶんが 1 コントロールの高さになります。横 1 行のときだけトラック
116
116
  内側の余白を引くので、積んだときは引きません。block は幅いっぱいに広げます。
117
117
  </CardDescription>
118
118
  </CardHeader>
@@ -147,7 +147,7 @@ export default function Demo() {
147
147
  </Card>
148
148
  <Card>
149
149
  <CardHeader>
150
- <CardTitle level={2}>バー以外の chrome — icon とパネルの向き</CardTitle>
150
+ <CardTitle level={2}>バー以外の chrome · icon とパネルの向き</CardTitle>
151
151
  <CardDescription>
152
152
  `appearance=&quot;icon&quot;` は正方形の ghost Button。バーではない chrome(nav rail、
153
153
  カードのヘッダ、ツールバー)のための箱で、glyph は同じです。 パネルは既定で chrome
@@ -172,7 +172,7 @@ export default function Demo() {
172
172
 
173
173
  <Card>
174
174
  <CardHeader>
175
- <CardTitle level={2}>Launchpad — 画面いっぱい</CardTitle>
175
+ <CardTitle level={2}>Launchpad · 画面いっぱい</CardTitle>
176
176
  <CardDescription>
177
177
  `responsive=&quot;fullscreen&quot;` は plaform の start bar のための形。背後のページを
178
178
  ぼかし、その地の上にタイルが並びます(macOS Launchpad / Windows Start)。 幅に関係なく
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "23.4.10",
4
- "godxUiMcp": "23.4.10",
3
+ "version": "23.4.12",
4
+ "godxUiMcp": "23.4.12",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -331,6 +331,7 @@
331
331
  "check:data-entry-touch-aria": "node scripts/check-data-entry-touch-aria.mjs",
332
332
  "check:display-runtime-evidence": "node scripts/check-display-runtime-evidence.mjs",
333
333
  "check:choice-hit-target": "node scripts/check-choice-hit-target.mjs",
334
+ "check:state-legible": "node scripts/check-state-legible.mjs",
334
335
  "check:app-shell-narrow-grid": "node scripts/check-app-shell-narrow-grid.mjs",
335
336
  "check:frame-contracts": "pnpm check:component-api-manifest && pnpm audit:component-cases && pnpm check:frame-coverage && pnpm check:frame-coverage-ledger && pnpm check:screen-reader-evidence && pnpm check:data-entry-owner-source && pnpm check:display-runtime-evidence",
336
337
  "check:frame-runtime": "pnpm check:data-entry-touch-aria && pnpm check:app-shell-narrow-grid && pnpm check:choice-hit-target",
@@ -528,8 +528,21 @@ const RULES = [
528
528
  {
529
529
  id: "hand-rolled-close-glyph",
530
530
  severity: "warn",
531
- // A literal close glyph as JSX text — almost always a hand-rolled dismiss that should be a slot.
531
+ /*
532
+ * A literal close glyph as JSX text — almost always a hand-rolled dismiss that should be a slot.
533
+ *
534
+ * NOT when the glyph carries a name. The rule's own message is "a bare glyph has no accessible
535
+ * name", so a glyph that HAS one is the pattern this rule wants, not the one it forbids:
536
+ *
537
+ * <span aria-hidden="true">×</span>
538
+ * <VisuallyHidden>未実施</VisuallyHidden>
539
+ *
540
+ * That is the ○ / △ / × status scale, and it was reported against our own `Segmented` docs page
541
+ * — one of ten standing warnings here. Flagging it would push every consumer doing the right
542
+ * thing toward either a suppression comment or a worse glyph.
543
+ */
532
544
  test: />\s*[✕✖×╳]\s*</,
545
+ exempt: /<VisuallyHidden\b|sr-only/,
533
546
  standard: "WAI-ARIA 1.2 (dialog) · WCAG 2.2 SC 4.1.2 · @godxjp/ui Alert/Dialog anatomy",
534
547
  message:
535
548
  "Don't hand-roll a ✕ close. Pass onDismiss to <Alert> (renders the × top-right with an aria-label), or use <Dialog>/<Sheet> which ship their own labelled close. A bare glyph has no accessible name.",
@@ -762,9 +775,25 @@ function isSuppressed(ruleId, sameLine, prevLine) {
762
775
 
763
776
  /** A rule's own in-place escape (see the `exempt` note on RULES), on the same line or the one above.
764
777
  * Read from the ORIGINAL lines, because the scanner blanks comments before `rule.test` runs. */
765
- function isExempt(rule, sameLine, prevLine) {
778
+ /**
779
+ * Also looks at the NEXT line, not just this one and the one before.
780
+ *
781
+ * An accessible name for a symbol is written AFTER the symbol, because that is the reading order:
782
+ *
783
+ * <span aria-hidden="true">×</span>
784
+ * <VisuallyHidden>未実施</VisuallyHidden>
785
+ *
786
+ * Checking only backwards could never see it, so `hand-rolled-close-glyph` fired on the ○/△/×
787
+ * status scale in our own `Segmented` docs — a glyph that is named, which is the pattern the rule
788
+ * wants rather than the one it forbids.
789
+ */
790
+ function isExempt(rule, sameLine, prevLine, nextLine) {
766
791
  if (!rule.exempt) return false;
767
- return rule.exempt.test(sameLine ?? "") || rule.exempt.test(prevLine ?? "");
792
+ return (
793
+ rule.exempt.test(sameLine ?? "") ||
794
+ rule.exempt.test(prevLine ?? "") ||
795
+ rule.exempt.test(nextLine ?? "")
796
+ );
768
797
  }
769
798
 
770
799
  function walk(dir, acc = []) {
@@ -857,12 +886,45 @@ function findHandCurrencyInJsxText(source, from) {
857
886
  return null;
858
887
  }
859
888
 
889
+ /**
890
+ * Is `index` inside a quoted string or a template literal, counted from the start of the file?
891
+ *
892
+ * `insideTemplateLiteral` only counts backticks, and only from an arbitrary `from` — enough for the
893
+ * text scan, not enough to decide whether a `<` is JSX at all. A tag written INSIDE a string is not
894
+ * markup, and treating it as markup sends the scan off through the rest of the file:
895
+ *
896
+ * console.warn(`[DataTable] Column "${key}" renders a <th> with no visible text…`)
897
+ *
898
+ * That `<th>` was read as an opening tag, the text scan walked past the closing backtick, skipped
899
+ * from quote to quote, and eventually found a currency glyph somewhere else entirely — reporting
900
+ * `hardcoded-currency` against a dev warning containing no currency at all. One of ten standing
901
+ * warnings in this repo, and the only one in `src/`.
902
+ */
903
+ function insideStringLiteral(source, index) {
904
+ let quote = "";
905
+ for (let i = 0; i < index; i++) {
906
+ const char = source[i];
907
+ if (char === "\\") {
908
+ i++;
909
+ continue;
910
+ }
911
+ if (quote) {
912
+ if (char === quote) quote = "";
913
+ } else if (char === '"' || char === "'" || char === "`") {
914
+ quote = char;
915
+ }
916
+ }
917
+ return quote !== "";
918
+ }
919
+
860
920
  /** Match real JSX text after a balanced opening tag, including props with comparisons. */
861
921
  function* currencyMatches(source) {
862
922
  for (const opening of source.matchAll(/<(?:[A-Za-z][\w.:]*\b|(?=>))/g)) {
863
923
  const end = jsxOpeningEnd(source, opening.index);
864
924
  if (end >= source.length) continue;
865
925
  if (isGenericTypeBeforeCall(source, opening.index, end)) continue;
926
+ // A tag inside a string or template literal is prose about markup, not markup.
927
+ if (insideStringLiteral(source, opening.index)) continue;
866
928
  const hit = findHandCurrencyInJsxText(source, end + 1);
867
929
  if (hit) {
868
930
  yield { 0: source.slice(opening.index, hit.end), index: opening.index };
@@ -1096,7 +1158,7 @@ for (const dir of SCAN_DIRS) {
1096
1158
  if (
1097
1159
  (typeof rule.test === "function" ? rule.test(target) : rule.test.test(target)) &&
1098
1160
  !suppressed(rule.id, i) &&
1099
- !isExempt(rule, origLines[i], origLines[i - 1])
1161
+ !isExempt(rule, origLines[i], origLines[i - 1], origLines[i + 1])
1100
1162
  ) {
1101
1163
  findings.push({
1102
1164
  file: rel,