@godxjp/ui 30.4.1 → 30.5.2

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.
Files changed (66) hide show
  1. package/agent/START-HERE.md +6 -6
  2. package/agent/components/Carousel.json +4 -1
  3. package/agent/components/RecordPicker.json +103 -0
  4. package/agent/components/ResponsiveGrid.json +3 -1
  5. package/agent/components/Reveal.json +6 -1
  6. package/agent/components/SpaceCompact.json +1 -1
  7. package/agent/components/Table.json +6 -0
  8. package/agent/components/Text.json +8 -0
  9. package/agent/components-index.json +5 -0
  10. package/agent/components.json +131 -4
  11. package/agent/index.json +7 -7
  12. package/agent/llms.txt +7 -7
  13. package/agent/patterns/brand-theme-block.json +33 -0
  14. package/agent/patterns-index.json +32 -0
  15. package/agent/patterns.json +33 -0
  16. package/agent/tokens.json +12 -0
  17. package/dist/components/data-display/table.d.ts +30 -1
  18. package/dist/components/data-display/table.js +2 -1
  19. package/dist/components/data-entry/index.d.ts +2 -0
  20. package/dist/components/data-entry/index.js +2 -0
  21. package/dist/components/data-entry/record-picker.d.ts +62 -0
  22. package/dist/components/data-entry/record-picker.js +296 -0
  23. package/dist/components/general/typography.d.ts +1 -0
  24. package/dist/components/general/typography.js +10 -0
  25. package/dist/contracts/measurement.json +1 -1
  26. package/dist/i18n/messages/en.json +18 -0
  27. package/dist/i18n/messages/ja.json +18 -0
  28. package/dist/i18n/messages/vi.json +18 -0
  29. package/dist/props/components/data-entry.prop.d.ts +58 -0
  30. package/dist/props/components/general.prop.d.ts +15 -1
  31. package/dist/props/registry.d.ts +17 -0
  32. package/dist/props/registry.js +17 -0
  33. package/dist/props/vocabulary/index.d.ts +1 -1
  34. package/dist/props/vocabulary/interaction.prop.d.ts +13 -0
  35. package/dist/styles/data-display-layout.css +6 -3
  36. package/dist/styles/data-entry-layout.css +71 -0
  37. package/dist/styles/dialog-layout.css +8 -0
  38. package/dist/styles/focus-ring.css +2 -0
  39. package/dist/styles/layout.css +4 -0
  40. package/dist/styles/table-layout.css +13 -0
  41. package/dist/styles/text-layout.css +8 -0
  42. package/dist/tokens/components/data-display.css +2 -0
  43. package/dist/tokens/components/data-entry.css +3 -0
  44. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  45. package/docs/assets/tcgm/card-1.svg +1 -0
  46. package/docs/assets/tcgm/card-2.svg +1 -0
  47. package/docs/assets/tcgm/card-3.svg +1 -0
  48. package/docs/assets/tcgm/card-4.svg +1 -0
  49. package/docs/assets/tcgm/card-5.svg +1 -0
  50. package/docs/assets/tcgm/card-6.svg +1 -0
  51. package/docs/assets/tcgm/hero-prism.svg +1 -0
  52. package/docs/assets/tcgm/scene-1.svg +1 -0
  53. package/docs/assets/tcgm/scene-2.svg +1 -0
  54. package/docs/assets/tcgm/scene-3.svg +1 -0
  55. package/docs/assets/tcgm/scene-4.svg +1 -0
  56. package/docs/assets/tcgm/scene-5.svg +1 -0
  57. package/docs/assets/tcgm/tcgm-lockup-color.svg +1 -0
  58. package/docs/assets/tcgm/tcgm-symbol-color.svg +1 -0
  59. package/docs/data-entry/record-picker.tsx +208 -0
  60. package/docs/general/typography.tsx +28 -0
  61. package/docs/i18n/messages/en.json +185 -1
  62. package/docs/i18n/messages/ja.json +185 -1
  63. package/docs/i18n/messages/vi.json +185 -1
  64. package/docs/showcase/tcgm-website.tsx +1047 -0
  65. package/package.json +3 -3
  66. package/scripts/ui-audit.mjs +23 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "aliases": [
3
+ "rebrand",
4
+ "design-handoff",
5
+ "brand-motion",
6
+ "motion-tokens",
7
+ "whitelabel",
8
+ "white-label-theme",
9
+ "theme-block",
10
+ "external-brand"
11
+ ],
12
+ "code": "// ─────────────────────────────────────────────────────────────────────────\n// A WHOLE BRAND AS A THEME BLOCK.\n//\n// tenantTheme(hex) is for ONE customer colour arriving as data. This is the\n// other job: a design system handed to you (brand book, Figma, an HTML\n// prototype) that has its own palette, radii, shadows, type ramp AND motion\n// timing. All of it is token configuration. If you find yourself forking a\n// component or overriding a .ui-* selector from a page stylesheet, stop —\n// that is the signal to file an issue for the missing knob instead.\n//\n// Worked screens, three different brands: /showcase/acme-website (light/gold),\n// /showcase/futurelastic-web (dark/gold), /showcase/tcgm-website (light,\n// Japanese, and the one with a MOTION spec).\n\n<div data-tenant=\"acme\"> {/* a REGION, never <html> */}\n <style>{THEME}</style> {/* in an app this is your theme.css */}\n <Navbar /> <Hero /> <Footer />\n</div>\n\nconst THEME = `\n[data-tenant=\"acme\"] {\n /* 1 ── COLOUR. Map the brand's hexes onto the ROLES, as HSL triplets. */\n --primary: 192 72% 21%; --primary-foreground: 0 0% 100%;\n --ring: 199 89% 29%; /* DECLARE IT. --ring is bound at :root and FREEZES;\n many brands ring in a different blue than they fill with. */\n --background: 210 40% 98%; --foreground: 195 57% 16%;\n --card: 0 0% 100%; --muted-foreground: 196 17% 39%;\n /* --border is decorative chrome. --input is the CONTROL BOUNDARY and is held to\n 3:1 (WCAG 2.2 SC 1.4.11) — brands usually ship a second, darker \"border-strong\"\n for exactly this; use THAT one here, not the hairline. */\n --border: 196 27% 89%; --input: 197 13% 51%;\n\n /* 2 ── SHAPE + ELEVATION. Card hover is a TOKEN PAIR, not a hand-written rule:\n \"lift 2px, shadow sm→md\" == --card-shadow + --card-hover-shadow. */\n --radius-md: 12px; --radius-xl: 20px;\n --card-radius: var(--radius-xl); --control-radius: var(--radius-md);\n --card-shadow: 0 1px 2px rgb(15 76 92 / .06);\n --card-hover-shadow: 0 12px 32px -14px rgb(15 76 92 / .20);\n\n /* 3 ── TYPE. --font-size-display is the BASE KNOB of the display ramp: 3xl/4xl/5xl\n all derive from it, so never restate --font-size-5xl. Use clamp() or the hero\n paints its desktop size on a phone (measured: 56px at 390 where the brand scale\n said 34px). */\n --font-family-body: \"Noto Sans JP\", system-ui, sans-serif;\n --font-family-sans: var(--font-family-body);\n --font-family-display: var(--font-family-body);\n --font-size-display: clamp(2.125rem, 4vw, 3.5rem);\n /* ❌ --font-family-display: \"Montserrat\", \"Noto Sans JP\", …\n A Latin-only face FIRST in a CJK product looks fine (the browser falls through\n per glyph) while silently setting the Latin inside a Japanese headline in a\n different face than the headline. Keep the CJK face as the display family and\n opt into the Latin face per element instead: */\n --brand-latin: \"Montserrat\", system-ui, sans-serif;\n}\n[data-tenant=\"acme\"] .brand-eyebrow,\n[data-tenant=\"acme\"] .brand-price { font-family: var(--brand-latin); }\n\n/* 4 ── MOTION. THIS IS THE PART PEOPLE FORK OVER, AND THEY DO NOT NEED TO.\n A brand motion table — press 90ms · hover 140 · state 200 · enter 260 ·\n hero-once 480ms at y8→0 · cubic-bezier(.2,.8,.2,1) — is EIGHT declarations.\n <Reveal>, Card hoverable, Dialog/Sheet enter and every token-driven transition\n retime themselves off these. Zero new @keyframes. */\n[data-tenant=\"acme\"] {\n --duration-fast: 140ms; /* hover / focus */\n --duration-base: 200ms; /* state change */\n --duration-slow: 480ms; /* hero once */\n --ease-standard: cubic-bezier(.2,.8,.2,1);\n --ease-emphasized: cubic-bezier(.2,.8,.2,1);\n --ease-decelerate: cubic-bezier(.2,.8,.2,1);\n --reveal-distance: 8px; /* the spec's \"y8→0\" */\n --reveal-stagger-step: 70ms;\n /* a11y numbers are tokens too — this brand's standard is a 44px target, above\n the WCAG 2.2 SC 2.5.8 floor of 24px (gh#931): */\n --carousel-arrow-size: 2.75rem;\n}\n`;\n\n// 5 ── SPEND THE MOTION, THEN STOP. Most brand books cap decorative motion\n// (\"one decorative region per viewport; no parallax, no count-up, no infinite\n// float\"). Read the spec before adding effects — adding more is not proving the\n// framework, it is breaking the brand.\n\n<Reveal on=\"mount\"> {/* hero: illustration only, if the spec says\n \"text appears immediately\" — then DON'T wrap\n the headline, so it paints on frame 1 */}\n <img src={heroArt} alt=\"…\" />\n</Reveal>\n\n<Reveal on=\"view\"> {/* one observer per SECTION */}\n <SectionHeading />\n</Reveal>\n<ResponsiveGrid columns={{ base: 2, sm: 3, lg: 4 }}>\n {items.map((it, i) => (\n <Reveal key={it.id} on=\"view\" delay={Math.min(i, 5) + 1}> {/* delay caps at 6 */}\n <Card hoverable>…</Card>\n </Reveal>\n ))}\n</ResponsiveGrid>\n\n// ❌ THE ONE THAT BITES: never put on=\"view\" on something a scroller CLIPS.\n// <Carousel><CarouselItem><Reveal on=\"view\">… ← 3 of 4 slides stayed at\n// opacity 0 FOREVER: the observer's root is the viewport and a clipped box has\n// an empty intersection rect, so a slide parked outside the rail never enters.\n// ✅ Reveal the RAIL:\n<Reveal on=\"view\">\n <Carousel>\n <CarouselContent>\n {news.map((n) => (\n <CarouselItem key={n.id} className=\"basis-4/5 sm:basis-1/2 lg:basis-1/3\">\n <Card hoverable>…</Card> {/* fractional basis, never basis-[82%] */}\n </CarouselItem>\n ))}\n </CarouselContent>\n </Carousel>\n</Reveal>\n\n// 6 ── VERIFY BY MEASURING, NOT BY SCREENSHOT. A full-page screenshot does not\n// fire viewport observers, so every scroll reveal photographs as invisible and\n// tells you nothing. Scroll the document, then count:\n// [...document.querySelectorAll('.ui-reveal')]\n// .filter(e => e.getAttribute('data-reveal-state') === 'out').length // must be 0\n// document.documentElement.scrollWidth - document.documentElement.clientWidth // must be 0\n// Check the hero's computed font-size at 390, and remember ResponsiveGrid steps\n// are CONTAINER widths: inside a 1280 shell with 32px gutters, a 768 tablet hands\n// the grid 704px, so `md` (48rem) never fires there — key that column to `sm`.\n",
13
+ "name": "brand-theme-block",
14
+ "tagline": "Wear a WHOLE external brand — its palette, shape, type AND its motion spec — as one scoped theme block of token declarations. No forked components, no page-local overrides of library selectors. Use when you are handed a design system (Figma/HTML prototype/brand book), not a single customer hex.",
15
+ "tags": [
16
+ "theme",
17
+ "brand",
18
+ "rebrand",
19
+ "handoff",
20
+ "motion",
21
+ "animation",
22
+ "reveal",
23
+ "typography",
24
+ "font",
25
+ "cjk",
26
+ "japanese",
27
+ "tokens",
28
+ "design-system",
29
+ "white-label",
30
+ "marketing",
31
+ "landing"
32
+ ]
33
+ }
@@ -346,5 +346,37 @@
346
346
  "white-label",
347
347
  "launcher"
348
348
  ]
349
+ },
350
+ {
351
+ "aliases": [
352
+ "rebrand",
353
+ "design-handoff",
354
+ "brand-motion",
355
+ "motion-tokens",
356
+ "whitelabel",
357
+ "white-label-theme",
358
+ "theme-block",
359
+ "external-brand"
360
+ ],
361
+ "name": "brand-theme-block",
362
+ "tagline": "Wear a WHOLE external brand — its palette, shape, type AND its motion spec — as one scoped theme block of token declarations. No forked components, no page-local overrides of library selectors. Use when you are handed a design system (Figma/HTML prototype/brand book), not a single customer hex.",
363
+ "tags": [
364
+ "theme",
365
+ "brand",
366
+ "rebrand",
367
+ "handoff",
368
+ "motion",
369
+ "animation",
370
+ "reveal",
371
+ "typography",
372
+ "font",
373
+ "cjk",
374
+ "japanese",
375
+ "tokens",
376
+ "design-system",
377
+ "white-label",
378
+ "marketing",
379
+ "landing"
380
+ ]
349
381
  }
350
382
  ]
@@ -366,5 +366,38 @@
366
366
  "white-label",
367
367
  "launcher"
368
368
  ]
369
+ },
370
+ {
371
+ "aliases": [
372
+ "rebrand",
373
+ "design-handoff",
374
+ "brand-motion",
375
+ "motion-tokens",
376
+ "whitelabel",
377
+ "white-label-theme",
378
+ "theme-block",
379
+ "external-brand"
380
+ ],
381
+ "code": "// ─────────────────────────────────────────────────────────────────────────\n// A WHOLE BRAND AS A THEME BLOCK.\n//\n// tenantTheme(hex) is for ONE customer colour arriving as data. This is the\n// other job: a design system handed to you (brand book, Figma, an HTML\n// prototype) that has its own palette, radii, shadows, type ramp AND motion\n// timing. All of it is token configuration. If you find yourself forking a\n// component or overriding a .ui-* selector from a page stylesheet, stop —\n// that is the signal to file an issue for the missing knob instead.\n//\n// Worked screens, three different brands: /showcase/acme-website (light/gold),\n// /showcase/futurelastic-web (dark/gold), /showcase/tcgm-website (light,\n// Japanese, and the one with a MOTION spec).\n\n<div data-tenant=\"acme\"> {/* a REGION, never <html> */}\n <style>{THEME}</style> {/* in an app this is your theme.css */}\n <Navbar /> <Hero /> <Footer />\n</div>\n\nconst THEME = `\n[data-tenant=\"acme\"] {\n /* 1 ── COLOUR. Map the brand's hexes onto the ROLES, as HSL triplets. */\n --primary: 192 72% 21%; --primary-foreground: 0 0% 100%;\n --ring: 199 89% 29%; /* DECLARE IT. --ring is bound at :root and FREEZES;\n many brands ring in a different blue than they fill with. */\n --background: 210 40% 98%; --foreground: 195 57% 16%;\n --card: 0 0% 100%; --muted-foreground: 196 17% 39%;\n /* --border is decorative chrome. --input is the CONTROL BOUNDARY and is held to\n 3:1 (WCAG 2.2 SC 1.4.11) — brands usually ship a second, darker \"border-strong\"\n for exactly this; use THAT one here, not the hairline. */\n --border: 196 27% 89%; --input: 197 13% 51%;\n\n /* 2 ── SHAPE + ELEVATION. Card hover is a TOKEN PAIR, not a hand-written rule:\n \"lift 2px, shadow sm→md\" == --card-shadow + --card-hover-shadow. */\n --radius-md: 12px; --radius-xl: 20px;\n --card-radius: var(--radius-xl); --control-radius: var(--radius-md);\n --card-shadow: 0 1px 2px rgb(15 76 92 / .06);\n --card-hover-shadow: 0 12px 32px -14px rgb(15 76 92 / .20);\n\n /* 3 ── TYPE. --font-size-display is the BASE KNOB of the display ramp: 3xl/4xl/5xl\n all derive from it, so never restate --font-size-5xl. Use clamp() or the hero\n paints its desktop size on a phone (measured: 56px at 390 where the brand scale\n said 34px). */\n --font-family-body: \"Noto Sans JP\", system-ui, sans-serif;\n --font-family-sans: var(--font-family-body);\n --font-family-display: var(--font-family-body);\n --font-size-display: clamp(2.125rem, 4vw, 3.5rem);\n /* ❌ --font-family-display: \"Montserrat\", \"Noto Sans JP\", …\n A Latin-only face FIRST in a CJK product looks fine (the browser falls through\n per glyph) while silently setting the Latin inside a Japanese headline in a\n different face than the headline. Keep the CJK face as the display family and\n opt into the Latin face per element instead: */\n --brand-latin: \"Montserrat\", system-ui, sans-serif;\n}\n[data-tenant=\"acme\"] .brand-eyebrow,\n[data-tenant=\"acme\"] .brand-price { font-family: var(--brand-latin); }\n\n/* 4 ── MOTION. THIS IS THE PART PEOPLE FORK OVER, AND THEY DO NOT NEED TO.\n A brand motion table — press 90ms · hover 140 · state 200 · enter 260 ·\n hero-once 480ms at y8→0 · cubic-bezier(.2,.8,.2,1) — is EIGHT declarations.\n <Reveal>, Card hoverable, Dialog/Sheet enter and every token-driven transition\n retime themselves off these. Zero new @keyframes. */\n[data-tenant=\"acme\"] {\n --duration-fast: 140ms; /* hover / focus */\n --duration-base: 200ms; /* state change */\n --duration-slow: 480ms; /* hero once */\n --ease-standard: cubic-bezier(.2,.8,.2,1);\n --ease-emphasized: cubic-bezier(.2,.8,.2,1);\n --ease-decelerate: cubic-bezier(.2,.8,.2,1);\n --reveal-distance: 8px; /* the spec's \"y8→0\" */\n --reveal-stagger-step: 70ms;\n /* a11y numbers are tokens too — this brand's standard is a 44px target, above\n the WCAG 2.2 SC 2.5.8 floor of 24px (gh#931): */\n --carousel-arrow-size: 2.75rem;\n}\n`;\n\n// 5 ── SPEND THE MOTION, THEN STOP. Most brand books cap decorative motion\n// (\"one decorative region per viewport; no parallax, no count-up, no infinite\n// float\"). Read the spec before adding effects — adding more is not proving the\n// framework, it is breaking the brand.\n\n<Reveal on=\"mount\"> {/* hero: illustration only, if the spec says\n \"text appears immediately\" — then DON'T wrap\n the headline, so it paints on frame 1 */}\n <img src={heroArt} alt=\"…\" />\n</Reveal>\n\n<Reveal on=\"view\"> {/* one observer per SECTION */}\n <SectionHeading />\n</Reveal>\n<ResponsiveGrid columns={{ base: 2, sm: 3, lg: 4 }}>\n {items.map((it, i) => (\n <Reveal key={it.id} on=\"view\" delay={Math.min(i, 5) + 1}> {/* delay caps at 6 */}\n <Card hoverable>…</Card>\n </Reveal>\n ))}\n</ResponsiveGrid>\n\n// ❌ THE ONE THAT BITES: never put on=\"view\" on something a scroller CLIPS.\n// <Carousel><CarouselItem><Reveal on=\"view\">… ← 3 of 4 slides stayed at\n// opacity 0 FOREVER: the observer's root is the viewport and a clipped box has\n// an empty intersection rect, so a slide parked outside the rail never enters.\n// ✅ Reveal the RAIL:\n<Reveal on=\"view\">\n <Carousel>\n <CarouselContent>\n {news.map((n) => (\n <CarouselItem key={n.id} className=\"basis-4/5 sm:basis-1/2 lg:basis-1/3\">\n <Card hoverable>…</Card> {/* fractional basis, never basis-[82%] */}\n </CarouselItem>\n ))}\n </CarouselContent>\n </Carousel>\n</Reveal>\n\n// 6 ── VERIFY BY MEASURING, NOT BY SCREENSHOT. A full-page screenshot does not\n// fire viewport observers, so every scroll reveal photographs as invisible and\n// tells you nothing. Scroll the document, then count:\n// [...document.querySelectorAll('.ui-reveal')]\n// .filter(e => e.getAttribute('data-reveal-state') === 'out').length // must be 0\n// document.documentElement.scrollWidth - document.documentElement.clientWidth // must be 0\n// Check the hero's computed font-size at 390, and remember ResponsiveGrid steps\n// are CONTAINER widths: inside a 1280 shell with 32px gutters, a 768 tablet hands\n// the grid 704px, so `md` (48rem) never fires there — key that column to `sm`.\n",
382
+ "name": "brand-theme-block",
383
+ "tagline": "Wear a WHOLE external brand — its palette, shape, type AND its motion spec — as one scoped theme block of token declarations. No forked components, no page-local overrides of library selectors. Use when you are handed a design system (Figma/HTML prototype/brand book), not a single customer hex.",
384
+ "tags": [
385
+ "theme",
386
+ "brand",
387
+ "rebrand",
388
+ "handoff",
389
+ "motion",
390
+ "animation",
391
+ "reveal",
392
+ "typography",
393
+ "font",
394
+ "cjk",
395
+ "japanese",
396
+ "tokens",
397
+ "design-system",
398
+ "white-label",
399
+ "marketing",
400
+ "landing"
401
+ ]
369
402
  }
370
403
  ]
package/agent/tokens.json CHANGED
@@ -4961,6 +4961,12 @@
4961
4961
  "tier": "component",
4962
4962
  "value": "var(--icon-size-md)"
4963
4963
  },
4964
+ {
4965
+ "description": "THE ARROW'S BOX, WHICH IS NOT THE SAME KNOB AS ITS GLYPH (gh#931). `--carousel-arrow-icon-size` sizes the mark INSIDE the button; the button itself was a pair of bare `2rem` literals in data-display-layout.css, so the TARGET had no tier-2 route at all. 32px clears WCAG 2.2 SC 2.5.8 (24px), which is why this is a missing knob and not a defect — but a brand whose own foundations ask for a 44px target could not reach it from a theme, and had no option left except a page-local override of a library selector. Reported from a real re-theme whose spec reads \"arrows44px\". The default is the same 2rem the literals painted, so no existing carousel moves. A theme that wants the larger target writes one line: `--carousel-arrow-size: 2.75rem`.",
4966
+ "name": "--carousel-arrow-size",
4967
+ "tier": "component",
4968
+ "value": "2rem"
4969
+ },
4964
4970
  {
4965
4971
  "description": "WHERE THE ARROWS SIT, AND THE DEFAULT IS THE MIDDLE OF THE CONTENT — NOT OF THE COMPONENT. `.ui-carousel` is the positioning context and it is as tall as the slides PLUS the dots, so a plain `50%` centres the arrows on that sum. Measured on a 98px carousel: slides 58px (middle at -3415), dots 40px, arrows landing at -3395 — exactly half the dots row too low, which is what \"tại sao lại là middle của cả khung thế\" is pointing at. The reserve below subtracts it back. default = the content's middle, at the call site",
4966
4972
  "name": "--carousel-arrow-inset-block-start",
@@ -5879,6 +5885,12 @@
5879
5885
  "tier": "component",
5880
5886
  "value": "var(--space-4)"
5881
5887
  },
5888
+ {
5889
+ "description": "RecordPicker — chiều cao TỐI ĐA của danh sách trong Dialog (gh#932). Một hằng số ở đây là thứ không theme nào với tới: một service dùng hàng dày hơn, hoặc một Dialog cao hơn, cần đổi được con số này mà không phải đè selector của thư viện.",
5890
+ "name": "--record-picker-list-max-block-size",
5891
+ "tier": "component",
5892
+ "value": "22rem"
5893
+ },
5882
5894
  {
5883
5895
  "description": "Data-entry component tokens — small-by-design text knobs (rule #45/#46).",
5884
5896
  "name": "--password-strength-score-font-size",
@@ -166,7 +166,36 @@ export declare const TableBody: React.ForwardRefExoticComponent<React.HTMLAttrib
166
166
  type TableRowTone = {
167
167
  tone?: TableRowToneProp;
168
168
  };
169
- export declare const TableRow: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLTableRowElement> & TableRowTone & React.RefAttributes<HTMLTableRowElement>>;
169
+ /**
170
+ * `interactive` — THE ROW IS THE TARGET (gh#929).
171
+ *
172
+ * `ListRow` already gives a clickable row all four of the things that make one usable: the whole
173
+ * row as the hit area, a pointer cursor, a row-level hover, and the design system's focus ring —
174
+ * because it wraps the `<a>` itself. A `<tr>` cannot be wrapped in an `<a>` and stay a table row,
175
+ * so every consumer building "a list with COLUMNS whose rows are selectable" fell into the same
176
+ * hole and patched it with page CSS, which `CONSUMER-RULES` forbids.
177
+ *
178
+ * Measured on a consumer (godx-jp/id, `/organization/roles`): the link occupied 160x66 of a
179
+ * 318x83 row, so the right half of every row was dead; `cursor` computed `auto` on every `<tr>`,
180
+ * so the hit area that did exist never advertised itself; and a bare `<Link>` in a `TableCell`
181
+ * fell back to Chromium's default ring (`rgb(0, 95, 204) auto 1px`) instead of the system's
182
+ * (`1px solid rgb(122, 0, 255)`), because `focus-ring.css` only names a fixed list of classes.
183
+ *
184
+ * This prop writes `data-interactive` and the package answers all three in CSS. It does NOT bind a
185
+ * handler and does NOT make the row focusable: a row is not a control, and the thing a keyboard
186
+ * user tabs to must still be a real `<a>`/`<button>` inside it — which is exactly what now gets
187
+ * the system's ring. Pair it with your own `onClick` on the row for the pointer affordance, and
188
+ * keep the real control in the first cell for the keyboard.
189
+ */
190
+ type TableRowInteractive = {
191
+ /**
192
+ * The whole row acts as one target: pointer cursor, a row-level hover that means "clickable"
193
+ * rather than merely "hovered", and the design-system focus ring for the real control inside it.
194
+ * Presentation only — bind the handler yourself and keep a genuine `<a>`/`<button>` in the row.
195
+ */
196
+ interactive?: boolean;
197
+ };
198
+ export declare const TableRow: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLTableRowElement> & TableRowTone & TableRowInteractive & React.RefAttributes<HTMLTableRowElement>>;
170
199
  /**
171
200
  * Column priority carried by BOTH the header cell and the body cells of a column. Read only by
172
201
  * `Table preset="action-collection"` below its collapse step, where it selects the column's
@@ -78,11 +78,12 @@ const TableBody = React.forwardRef(({ className, ...props }, ref) => (
78
78
  /* @__PURE__ */ jsx("tbody", { ref, "data-slot": "table-body", className: cn(className), ...props })
79
79
  ));
80
80
  TableBody.displayName = "TableBody";
81
- const TableRow = React.forwardRef(({ className, tone, ...props }, ref) => /* @__PURE__ */ jsx(
81
+ const TableRow = React.forwardRef(({ className, tone, interactive, ...props }, ref) => /* @__PURE__ */ jsx(
82
82
  "tr",
83
83
  {
84
84
  ref,
85
85
  "data-tone": tone,
86
+ "data-interactive": interactive ? "" : void 0,
86
87
  className: cn(
87
88
  // The row rule itself is `.ui-table-row` in table-layout.css (--table-row-border-width),
88
89
  // NOT a `border-b` utility — a utility sits in `@layer utilities` and would outrank the
@@ -22,6 +22,8 @@ export type { FormErrorsProp, FormErrorsProps, FormErrorsProviderProp, FormError
22
22
  export { Field } from "./field.js";
23
23
  export type { FieldProps } from "./field.js";
24
24
  export { SearchInput } from "./search-input.js";
25
+ export { RecordPicker } from "./record-picker.js";
26
+ export type { RecordPickerProp, RecordPickerProps } from "./record-picker.js";
25
27
  export { Switch } from "./switch.js";
26
28
  export type { SwitchProps } from "./switch.js";
27
29
  export { Toggle } from "./toggle.js";
@@ -24,6 +24,7 @@ import { FormField } from "./form-field.js";
24
24
  import { FormErrors, FormErrorsProvider } from "./form-errors.js";
25
25
  import { Field } from "./field.js";
26
26
  import { SearchInput } from "./search-input.js";
27
+ import { RecordPicker } from "./record-picker.js";
27
28
  import { Switch } from "./switch.js";
28
29
  import { Toggle } from "./toggle.js";
29
30
  import { ToggleGroup, ToggleGroupItem } from "./toggle-group.js";
@@ -100,6 +101,7 @@ export {
100
101
  RadioGroupRoot,
101
102
  RadioItem,
102
103
  Rating,
104
+ RecordPicker,
103
105
  SHOW_ALL,
104
106
  SHOW_CHILD,
105
107
  SHOW_PARENT,
@@ -0,0 +1,62 @@
1
+ import * as React from "react";
2
+ import type { RecordPickerProp, SearchSelectOptionProp, SelectOptionGroupProp } from "../../props/components/data-entry.prop.js";
3
+ export type { RecordPickerProp, RecordPickerProp as RecordPickerProps };
4
+ /**
5
+ * RecordPicker — ONE control whose SHAPE follows the size of the set behind it (gh#932).
6
+ *
7
+ * The report, from a `/human-tester` session on a consumer: "các select mà có khả năng nhiều dữ
8
+ * liệu mà hiển thị kiểu dropdown có cả nghìn cái thì làm sao chọn được… dưới X người là dropdown,
9
+ * trên X người là cho cấu hình search condition trong modal". A dropdown is the right control for
10
+ * eight people and the wrong one for eight hundred, and today the consumer has to decide that by
11
+ * hand, per screen — which is why the same concept ("pick a person") shipped three different ways
12
+ * in one app, including a bare text field where a mistyped key failed with a 422 after save.
13
+ *
14
+ * ## Why this is a component and not a composition
15
+ *
16
+ * By the Framework-Component Test it passes on every count: three unrelated screens need it, the
17
+ * behaviour is stateful and keyboard/ARIA-bearing rather than arrangement, and the knob that
18
+ * matters (`threshold`) is a design decision a service sets once. What it does NOT do is invent
19
+ * anything: under the threshold it renders `Select` — the same component, same props, same panel —
20
+ * and over it, the dialog body is `Command` with its own filtering off, which is exactly how
21
+ * `Select` already drives a server-backed list.
22
+ *
23
+ * ## The threshold is counted, not guessed
24
+ *
25
+ * `count` is the size of the WHOLE set, which a server knows and a page of results does not. With
26
+ * `options` it defaults to their length. The default 10 is the owner's own number ("dưới 10 thì
27
+ * dropdown, trên 10 thì mở modal search"), and a service overrides it once.
28
+ *
29
+ * ## Selected values survive a page they are not on
30
+ *
31
+ * A picker over ten thousand records will routinely hold a value whose row is not in the current
32
+ * result page — on first render, there IS no result page. `selectedOptions` is how the consumer
33
+ * hands over the labels it already has, and it is merged ahead of whatever loads later, so a chip
34
+ * never renders as a raw id and never disappears when the query changes.
35
+ */
36
+ export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "defaultValue" | "onChange" | "value"> & {
37
+ mode?: "single" | "multiple";
38
+ value?: string | string[] | null;
39
+ defaultValue?: string | string[] | null;
40
+ onValueChange?: (value: string | string[] | null) => void;
41
+ options?: (SearchSelectOptionProp | SelectOptionGroupProp)[];
42
+ loadOptions?: (params: {
43
+ query: string;
44
+ filters: Record<string, string>;
45
+ cursor?: string;
46
+ }) => Promise<{
47
+ options: SearchSelectOptionProp[];
48
+ count?: number;
49
+ nextCursor?: string;
50
+ }>;
51
+ count?: number;
52
+ threshold?: number;
53
+ filters?: import("../../props/components/data-entry.prop.js").RecordPickerFilterProp[];
54
+ selectedOptions?: SearchSelectOptionProp[];
55
+ emptyOption?: {
56
+ value: string;
57
+ label: string;
58
+ };
59
+ placeholder?: string;
60
+ dialogTitle?: string;
61
+ size?: "xs" | "sm" | "md" | "lg";
62
+ } & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,296 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Check, ChevronDown, X } from "lucide-react";
4
+ import * as React from "react";
5
+ import { useTranslation } from "../../i18n/use-translation.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ import { normalizeSelectOptions } from "../../lib/select-options.js";
8
+ import { Badge } from "../data-display/badge.js";
9
+ import { EmptyState } from "../data-display/empty-state.js";
10
+ import { Button } from "../general/button.js";
11
+ import { Text } from "../general/typography.js";
12
+ import { Flex } from "../layout/flex.js";
13
+ import {
14
+ Dialog,
15
+ DialogBody,
16
+ DialogContent,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle
20
+ } from "../feedback/dialog.js";
21
+ import { Command, CommandGroup } from "./command.js";
22
+ import { Input } from "./input.js";
23
+ import { Select } from "./select.js";
24
+ const RecordPicker = React.forwardRef(
25
+ function RecordPicker2({
26
+ mode = "single",
27
+ value,
28
+ defaultValue,
29
+ onValueChange,
30
+ options,
31
+ loadOptions,
32
+ count,
33
+ threshold = 10,
34
+ filters,
35
+ selectedOptions,
36
+ emptyOption,
37
+ placeholder,
38
+ dialogTitle,
39
+ disabled = false,
40
+ size,
41
+ className,
42
+ ...props
43
+ }, ref) {
44
+ const { t } = useTranslation();
45
+ const isMultiple = mode === "multiple";
46
+ const controlled = value !== void 0;
47
+ const [internal, setInternal] = React.useState(
48
+ () => toArray(defaultValue ?? (isMultiple ? [] : null))
49
+ );
50
+ const selected = controlled ? toArray(value) : internal;
51
+ const commit = React.useCallback(
52
+ (next) => {
53
+ if (!controlled) setInternal(next);
54
+ onValueChange?.(isMultiple ? next : next[0] ?? null);
55
+ },
56
+ [controlled, isMultiple, onValueChange]
57
+ );
58
+ const staticOptions = React.useMemo(
59
+ () => options ? normalizeSelectOptions(options) : [],
60
+ [options]
61
+ );
62
+ const total = count ?? (options ? staticOptions.length : void 0);
63
+ const asDialog = total === void 0 ? Boolean(loadOptions) : total > threshold;
64
+ if (!asDialog) {
65
+ return /* @__PURE__ */ jsx(
66
+ Select,
67
+ {
68
+ mode: isMultiple ? "multiple" : void 0,
69
+ labelInValue: false,
70
+ showSearch: true,
71
+ options: withEmptyOption(options, emptyOption),
72
+ value: isMultiple ? selected : selected[0] ?? void 0,
73
+ onValueChange: ((next) => commit(toArray(next))),
74
+ placeholder: placeholder ?? t("dataEntry.recordPicker.placeholder"),
75
+ disabled,
76
+ size,
77
+ className
78
+ }
79
+ );
80
+ }
81
+ return /* @__PURE__ */ jsx(
82
+ DialogPicker,
83
+ {
84
+ ref,
85
+ isMultiple,
86
+ selected,
87
+ commit,
88
+ staticOptions,
89
+ loadOptions,
90
+ filters,
91
+ selectedOptions,
92
+ emptyOption,
93
+ placeholder,
94
+ dialogTitle,
95
+ disabled,
96
+ size,
97
+ className,
98
+ ...props
99
+ }
100
+ );
101
+ }
102
+ );
103
+ function toArray(v) {
104
+ if (v === null || v === void 0) return [];
105
+ return Array.isArray(v) ? v : [v];
106
+ }
107
+ function withEmptyOption(options, emptyOption) {
108
+ if (!emptyOption) return options;
109
+ return [{ value: emptyOption.value, label: emptyOption.label }, ...options ?? []];
110
+ }
111
+ const DialogPicker = React.forwardRef(function DialogPicker2({
112
+ isMultiple,
113
+ selected,
114
+ commit,
115
+ staticOptions,
116
+ loadOptions,
117
+ filters,
118
+ selectedOptions,
119
+ emptyOption,
120
+ placeholder,
121
+ dialogTitle,
122
+ disabled,
123
+ size,
124
+ className,
125
+ ...props
126
+ }, ref) {
127
+ const { t } = useTranslation();
128
+ const [open, setOpen] = React.useState(false);
129
+ const [query, setQuery] = React.useState("");
130
+ const [filterValues, setFilterValues] = React.useState({});
131
+ const [rows, setRows] = React.useState(staticOptions);
132
+ const [status, setStatus] = React.useState("idle");
133
+ const [draft, setDraft] = React.useState(selected);
134
+ React.useEffect(() => {
135
+ if (open) setDraft(selected);
136
+ }, [open, selected]);
137
+ const labels = React.useMemo(() => {
138
+ const map = /* @__PURE__ */ new Map();
139
+ for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) map.set(o.value, o);
140
+ if (emptyOption) map.set(emptyOption.value, { value: emptyOption.value, label: emptyOption.label });
141
+ return map;
142
+ }, [selectedOptions, staticOptions, rows, emptyOption]);
143
+ const load = React.useCallback(
144
+ async (q, f) => {
145
+ if (!loadOptions) return;
146
+ setStatus("loading");
147
+ try {
148
+ const result = await loadOptions({ query: q, filters: f });
149
+ setRows(result.options);
150
+ setStatus("idle");
151
+ } catch {
152
+ setStatus("error");
153
+ }
154
+ },
155
+ [loadOptions]
156
+ );
157
+ React.useEffect(() => {
158
+ if (!open || !loadOptions) return;
159
+ const id = setTimeout(() => void load(query, filterValues), 250);
160
+ return () => clearTimeout(id);
161
+ }, [open, query, filterValues, load, loadOptions]);
162
+ const visible = React.useMemo(() => {
163
+ const base = loadOptions ? rows : staticOptions.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()));
164
+ return emptyOption && !query.trim() ? [{ value: emptyOption.value, label: emptyOption.label }, ...base] : base;
165
+ }, [loadOptions, rows, staticOptions, query, emptyOption]);
166
+ const groups = React.useMemo(() => {
167
+ const out = /* @__PURE__ */ new Map();
168
+ for (const o of visible) {
169
+ const key = o.group ?? "";
170
+ out.set(key, [...out.get(key) ?? [], o]);
171
+ }
172
+ return [...out.entries()];
173
+ }, [visible]);
174
+ const toggle = (optionValue) => {
175
+ if (!isMultiple) {
176
+ commit([optionValue]);
177
+ setOpen(false);
178
+ return;
179
+ }
180
+ setDraft(
181
+ (prev) => prev.includes(optionValue) ? prev.filter((v) => v !== optionValue) : [...prev, optionValue]
182
+ );
183
+ };
184
+ const chips = selected.map((v) => labels.get(v) ?? { value: v, label: v });
185
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
186
+ /* @__PURE__ */ jsxs(
187
+ Button,
188
+ {
189
+ ref,
190
+ type: "button",
191
+ variant: "outline",
192
+ size,
193
+ disabled,
194
+ "aria-haspopup": "dialog",
195
+ onClick: () => setOpen(true),
196
+ className: cn("ui-record-picker-trigger", className),
197
+ ...props,
198
+ children: [
199
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
200
+ c.icon,
201
+ c.label
202
+ ] }, c.value)) }) }),
203
+ /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
204
+ ]
205
+ }
206
+ ),
207
+ /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
208
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
209
+ /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
210
+ /* @__PURE__ */ jsx(
211
+ Input,
212
+ {
213
+ autoFocus: true,
214
+ type: "search",
215
+ value: query,
216
+ onChange: (e) => setQuery(e.target.value),
217
+ "aria-label": t("dataEntry.recordPicker.search"),
218
+ placeholder: t("dataEntry.recordPicker.searchPlaceholder")
219
+ }
220
+ ),
221
+ filters?.length ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "sm", wrap: true, role: "group", "aria-label": t("dataEntry.recordPicker.filters"), children: filters.map((f) => /* @__PURE__ */ jsx(
222
+ Select,
223
+ {
224
+ size: "sm",
225
+ "aria-label": f.label,
226
+ placeholder: f.label,
227
+ value: filterValues[f.name] ?? "",
228
+ onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
229
+ options: [
230
+ { value: "", label: t("dataEntry.recordPicker.allFilter") },
231
+ ...f.options
232
+ ]
233
+ },
234
+ f.name
235
+ )) }) : null,
236
+ /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
237
+ EmptyState,
238
+ {
239
+ variant: "compact",
240
+ tone: "destructive",
241
+ title: t("dataEntry.recordPicker.error"),
242
+ action: /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => void load(query, filterValues), children: t("dataEntry.recordPicker.more") })
243
+ }
244
+ ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
245
+ const rendered = items.map((o) => {
246
+ const picked = (isMultiple ? draft : selected).includes(o.value);
247
+ return /* @__PURE__ */ jsxs(
248
+ "button",
249
+ {
250
+ type: "button",
251
+ role: "option",
252
+ "aria-selected": picked,
253
+ "data-picked": picked ? "" : void 0,
254
+ disabled: o.disabled,
255
+ className: "ui-record-picker-option",
256
+ onClick: () => toggle(o.value),
257
+ children: [
258
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: picked ? /* @__PURE__ */ jsx(Check, {}) : null }),
259
+ o.icon,
260
+ /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
261
+ o.label,
262
+ o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
263
+ ] })
264
+ ]
265
+ },
266
+ o.value
267
+ );
268
+ });
269
+ return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
270
+ }) })
271
+ ] }) }),
272
+ isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
273
+ /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
274
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setDraft([]), children: [
275
+ /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
276
+ t("dataEntry.recordPicker.clear")
277
+ ] }),
278
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setOpen(false), children: t("dataEntry.recordPicker.cancel") }),
279
+ /* @__PURE__ */ jsx(
280
+ Button,
281
+ {
282
+ size: "sm",
283
+ onClick: () => {
284
+ commit(draft);
285
+ setOpen(false);
286
+ },
287
+ children: t("dataEntry.recordPicker.confirm")
288
+ }
289
+ )
290
+ ] }) : null
291
+ ] }) })
292
+ ] });
293
+ });
294
+ export {
295
+ RecordPicker
296
+ };
@@ -20,6 +20,7 @@ export declare const Text: React.ForwardRefExoticComponent<Omit<React.HTMLAttrib
20
20
  clamp?: number;
21
21
  ellipsis?: boolean | Omit<TypographyEllipsisConfigProp, "rows" | "expandable" | "onExpand">;
22
22
  whitespace?: import("../../props/index.js").TextWhitespaceProp;
23
+ break?: import("../../props/index.js").TextBreakProp;
23
24
  tabular?: boolean;
24
25
  decoration?: "none" | "underline" | "line-through";
25
26
  chip?: boolean;
@@ -380,6 +380,8 @@ const TextBase = React.forwardRef((props, ref) => {
380
380
  truncate,
381
381
  clamp,
382
382
  whitespace,
383
+ // `break` is a reserved word, so it cannot be a binding name.
384
+ break: breakMode,
383
385
  tabular,
384
386
  decoration,
385
387
  chip,
@@ -424,6 +426,11 @@ const TextBase = React.forwardRef((props, ref) => {
424
426
  'Text: `truncate` and `whitespace="pre-wrap"` are mutually exclusive \u2014 `truncate` takes precedence (one line, one ellipsis); use `clamp` to keep preserved line breaks and still bound the height.'
425
427
  );
426
428
  }
429
+ if (breakMode === "anywhere" && truncating) {
430
+ console.warn(
431
+ 'Text: `truncate` and `break="anywhere"` are mutually exclusive \u2014 `truncate` takes precedence (one line has nowhere to break to); use `clamp` to split the token and still bound the height.'
432
+ );
433
+ }
427
434
  if (ellipsis && (truncate || clamp !== void 0)) {
428
435
  console.warn(
429
436
  "Text: `ellipsis` (antd) and `truncate`/`clamp` are the same axis \u2014 `ellipsis` takes precedence; drop the other."
@@ -452,6 +459,9 @@ const TextBase = React.forwardRef((props, ref) => {
452
459
  // Inert default: `normal` is CSS's own behaviour, so it emits no attribute and there is no
453
460
  // `[data-whitespace="normal"]` rule to lose a specificity argument with anything.
454
461
  "data-whitespace": whitespace === "pre-wrap" && !truncating ? "pre-wrap" : void 0,
462
+ // Same shape and the same precedence as `whitespace`: `normal` is inert, and one line has
463
+ // nowhere to break to, so `truncate` simply does not emit it (gh#927).
464
+ "data-break": breakMode === "anywhere" && !truncating ? "anywhere" : void 0,
455
465
  style: clampStyle,
456
466
  "data-tabular": tabular ? "" : void 0,
457
467
  "data-decoration": decoration,
@@ -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": "30.4.1",
3
+ "version": "30.5.2",
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,