From 8b299ed8174c5f2bbd56debb0c20c48fa86b403d Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 7 Oct 2026 18:43:17 +0200 Subject: [PATCH 1/5] feat(ui): add cart LineItem and TotalsSummary --- .../skills/component-usage-ux/SKILL.md | 2 + .../skills/line-item-usage/SKILL.md | 91 ++++++ .../skills/totals-summary-usage/SKILL.md | 69 +++++ .../references/component-selection.md | 2 + .../docs/cart-presentation-figma-handoff.md | 27 ++ libs/ui/skills/_artifacts/domain_map.yaml | 3 +- libs/ui/skills/_artifacts/skill_spec.md | 2 +- libs/ui/skills/_artifacts/skill_tree.yaml | 36 +++ libs/ui/skills/component-usage-ux/SKILL.md | 2 + libs/ui/skills/line-item-usage/SKILL.md | 100 +++++++ libs/ui/skills/totals-summary-usage/SKILL.md | 76 +++++ .../references/component-selection.md | 2 + libs/ui/src/molecules/totals-summary.tsx | 112 +++++++ libs/ui/src/organisms/line-item.tsx | 279 ++++++++++++++++++ libs/ui/src/tokens/components/components.css | 2 + .../components/molecules/_totals-summary.css | 25 ++ .../components/organisms/_line-item.css | 31 ++ .../stories/changelog/changelog.stories.tsx | 6 + .../molecules/totals-summary.stories.tsx | 166 +++++++++++ .../stories/organisms/line-item.stories.tsx | 260 ++++++++++++++++ .../templates/cart-presentation.stories.tsx | 116 ++++++++ libs/ui/test/line-item.spec.ts | 89 ++++++ libs/ui/test/totals-summary.spec.ts | 91 ++++++ libs/ui/tests/line-item.types.test.ts | 67 +++++ libs/ui/tests/totals-summary.types.test.ts | 31 ++ 25 files changed, 1685 insertions(+), 2 deletions(-) create mode 100644 libs/ui/agent-plugin/skills/line-item-usage/SKILL.md create mode 100644 libs/ui/agent-plugin/skills/totals-summary-usage/SKILL.md create mode 100644 libs/ui/docs/cart-presentation-figma-handoff.md create mode 100644 libs/ui/skills/line-item-usage/SKILL.md create mode 100644 libs/ui/skills/totals-summary-usage/SKILL.md create mode 100644 libs/ui/src/molecules/totals-summary.tsx create mode 100644 libs/ui/src/organisms/line-item.tsx create mode 100644 libs/ui/src/tokens/components/molecules/_totals-summary.css create mode 100644 libs/ui/src/tokens/components/organisms/_line-item.css create mode 100644 libs/ui/stories/molecules/totals-summary.stories.tsx create mode 100644 libs/ui/stories/organisms/line-item.stories.tsx create mode 100644 libs/ui/stories/templates/cart-presentation.stories.tsx create mode 100644 libs/ui/test/line-item.spec.ts create mode 100644 libs/ui/test/totals-summary.spec.ts create mode 100644 libs/ui/tests/line-item.types.test.ts create mode 100644 libs/ui/tests/totals-summary.types.test.ts diff --git a/libs/ui/agent-plugin/skills/component-usage-ux/SKILL.md b/libs/ui/agent-plugin/skills/component-usage-ux/SKILL.md index 37e4b80418..2ff2174c55 100644 --- a/libs/ui/agent-plugin/skills/component-usage-ux/SKILL.md +++ b/libs/ui/agent-plugin/skills/component-usage-ux/SKILL.md @@ -49,6 +49,8 @@ Tree widget with arrow-key selection -> tree-view-usage Loading placeholder -> skeleton-usage Catalog search navigation -> search-suggestions-usage Catalog facet filtering -> facet-filter-panel-usage +Cart/order product row -> line-item-usage +Prepared amount breakdown and total -> totals-summary-usage Quantity with unit/help/error/pending -> quantity-field-usage Bare numeric draft or stepper -> numeric-input-usage Shortcut hint or keyboard registration -> hotkeys-usage diff --git a/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md new file mode 100644 index 0000000000..fb5a005184 --- /dev/null +++ b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md @@ -0,0 +1,91 @@ +--- +name: line-item-usage +description: > + Use after component-usage-ux when an app needs @techsio/ui-kit LineItem + compound composition for editable cart rows, compact previews or read-only recaps. +metadata: + component_version: "1.0.0" + type: "core" + library: "@techsio/ui-kit" + library_version: "0.3.2" + requires: "component-usage-ux quantity-field-usage price-block-usage app-token-overrides ux-guidelines" + sources: "libs/ui/src/organisms/line-item.tsx libs/ui/src/tokens/components/organisms/_line-item.css libs/ui/stories/organisms/line-item.stories.tsx libs/ui/docs/cart-presentation-figma-handoff.md" +--- + +# @techsio/ui-kit LineItem usage + +LineItem is a compound presentation organism. Applications own DTO mapping, +formatting, routing, inventory, validation, saving and removal requests. + +## Setup + +```tsx +import { LineItem } from "@techsio/ui-kit/organisms/line-item" +import { PriceBlock } from "@techsio/ui-kit/molecules/price-block" +import { QuantityField } from "@techsio/ui-kit/molecules/quantity-field" + + + + + + + + 100 ks · pozink + + + setDraft(value)} /> + + + + + +``` + +## Compound contract + +- Root requires plain product `title` and `children`. `layout="compact"` reduces + image size and spacing. Root shares layout, readOnly and pending through context. +- Parts must render inside LineItem; otherwise they throw a descriptive error. +- `Image` wraps Image or the app's image adapter. It is optional; omit the whole + part if no image is available. The default grid uses two columns for a direct + Image child and one column without it. +- `Body`, `Heading`, `Title`, `Price`, `Options` and `Controls` arrange content. + `Title` defaults to the root title, or accepts a Link containing the same name. + `Price` accepts PriceBlock or an already formatted line amount. +- Supply AvailabilityStatus and StatusText directly where the composition needs + availability and item-level error feedback. No new engine is added for either. +- `Quantity` requires `readOnlyLabel` even in an editable composition. In readOnly + it renders that text and does not mount its editable children. In pending it + makes the editable children inert. Prefer QuantityField and pass pending to it + as well for its visual and native input state. +- Put custom mutation actions inside `Actions`. In readOnly the whole part is + omitted; pending makes its children inert. App-owned actions should also use + disabled for visual feedback. Navigation links may remain outside Actions. +- `Remove` renders the kit Button with a required visible localized `label`, adds + the root product title to its accessible name and forwards Button props/ref. + It is omitted in readOnly and disabled in pending even outside Actions. + Callers cannot override its accessible name via aria-label/aria-labelledby. +- `pending` requires localized `pendingLabel`. Root renders and associates the + feedback automatically below the composed content; no implicit live region. +- The root cannot inspect arbitrary child components. Use Quantity, Actions and + Remove for editable controls so the shared state guards apply. + +## UX/UI guidelines + +- Keep product name, options, price and quantity recognizable across contexts. +- Use compact presentation for a preview and ordinary spacing for the main cart. +- Unavailable items may remain removable. The app decides quantity restrictions. +- Keep a rejected draft visible and place its error near the relevant item. +- Preserve the supplied amounts while updating; never calculate totals here. +- Read-only recaps show quantity text and omit mutation actions. + +## Tokens and stories + +Override LineItem tokens for local presentation needs. Brand-wide changes belong +in semantic tokens. Styles use `src/tokens/components/organisms/_line-item.css`. + +Organisms/LineItem covers editable, read-only, pending, unavailable, item error, +compact/long content and CustomComposition. The custom story demonstrates an +omitted image, a relocated price and extra actions with the same shared guards. +Figma handoff follows component-to-figma after API stabilization. diff --git a/libs/ui/agent-plugin/skills/totals-summary-usage/SKILL.md b/libs/ui/agent-plugin/skills/totals-summary-usage/SKILL.md new file mode 100644 index 0000000000..3b5d2a7306 --- /dev/null +++ b/libs/ui/agent-plugin/skills/totals-summary-usage/SKILL.md @@ -0,0 +1,69 @@ +--- +name: totals-summary-usage +description: > + Use after component-usage-ux when an app needs @techsio/ui-kit TotalsSummary + to present prepared cart or checkout rows, unresolved fees, discounts, + a labeled total and actions blocked while updating. +metadata: + component_version: "1.0.0" + type: "core" + library: "@techsio/ui-kit" + library_version: "0.3.2" + requires: "component-usage-ux app-token-overrides ux-guidelines" + sources: "libs/ui/src/molecules/totals-summary.tsx libs/ui/src/tokens/components/molecules/_totals-summary.css libs/ui/stories/molecules/totals-summary.stories.tsx libs/ui/docs/cart-presentation-figma-handoff.md" +--- + +# @techsio/ui-kit TotalsSummary Usage + +Use TotalsSummary for a cart or checkout breakdown of already prepared labels +and values. Applications own calculations, currency formatting, tax rules, +transport/payment selection, requests and order submission. + +## Setup + +```tsx +import { TotalsSummary } from "@techsio/ui-kit/molecules/totals-summary" + + +``` + +## Contract + +- rows is a readonly array with stable unique id, label and value strings. +- Each row may have note and emphasis (default or discount). +- totalLabel and totalValue are required strings supplied by the application. +- footer and actions accept consumer content. +- Native div attributes and a div ref are supported. +- pending: true requires a localized pendingLabel. The last supplied values + remain visible, aria-busy describes the update, and the actions wrapper is inert. + Add disabled to action buttons when a disabled appearance is desired. +- The component does not create a live region. The application owns announcements + of completed requests or errors outside the busy summary. + +## UX/UI guidelines + +Load ux-guidelines and app-token-overrides for composition and appearance. +Do not represent an unselected fee as zero. Supply a clear unresolved label +and call the total a subtotal when final fees are missing. Include the tax +convention in the total label or footer. Show a discount as a separate negative +value; emphasis does not change its amount or sign. + +Use PriceBlock for one product's price. Use TotalsSummary for a labeled breakdown. +Do not pass Medusa DTOs, calculation functions or mutation hooks into the summary. +Do not use row notes or footer for mutable order controls: only the actions slot +is blocked while pending. + +## Stories + +Playground exposes labels, rows, total and pending controls. Partial preserves +unselected fees, Complete shows final fees and an action, Discounted shows a +negative discount, Pending retains amounts and blocks the checkout action, +and CustomRows demonstrates tax and deposit rows without a fixed schema. diff --git a/libs/ui/agent-plugin/skills/ux-guidelines/references/component-selection.md b/libs/ui/agent-plugin/skills/ux-guidelines/references/component-selection.md index 41eedd8def..964292cb98 100644 --- a/libs/ui/agent-plugin/skills/ux-guidelines/references/component-selection.md +++ b/libs/ui/agent-plugin/skills/ux-guidelines/references/component-selection.md @@ -80,6 +80,8 @@ Each `-usage` skill repeats its own rows in its UX/UI section. | Product image with thumbnails / zoom | `Gallery` | `Carousel` | | Horizontally browsable slides (banners, related products) | `Carousel` | `Gallery` | | Product in a listing | `ProductCard` | custom card divs | +| Product row in cart or order recap | `LineItem` | `ProductCard`, app-owned repeated row markup | +| Prepared amount breakdown and total | `TotalsSummary` | component-owned fee calculations | | Catalog facets | `FacetFilterPanel` | hand-built accordions of checkboxes | | Images with fixed ratio / framework optimisation | `Image` (`as={NextImage}`) | `` | | Decorative or adjacent icon | `Icon` | emoji, inline SVG | diff --git a/libs/ui/docs/cart-presentation-figma-handoff.md b/libs/ui/docs/cart-presentation-figma-handoff.md new file mode 100644 index 0000000000..c3bbcede7f --- /dev/null +++ b/libs/ui/docs/cart-presentation-figma-handoff.md @@ -0,0 +1,27 @@ +# PR10 cart presentation: Figma handoff + +LineItem and TotalsSummary implement audit C01/C02. Both are presentational; application formatting, shipping selection, inventory, totals and mutations remain application-owned. + +## Component ownership + +- LineItem is a compound organism. Root shares title, layout, pending and readOnly. Image, Body, Heading, Title, Price, Options and Controls arrange application-provided content. Quantity requires readOnlyLabel and replaces editable children with text in readOnly. Actions omits mutation controls in readOnly and blocks them while pending. Remove uses a localized label containing the root product name, is omitted in readOnly and disabled while pending. Root pending feedback is automatic. AvailabilityStatus and StatusText can be inserted directly; image, price and actions can be omitted or rearranged. +- TotalsSummary is a molecule. It renders supplied label/value rows and notes in a definition list, a separately labeled total, optional footer and actions. Pending preserves the supplied values and blocks actions. An unresolved fee must be supplied as text such as `Choose delivery`, not assumed to be zero. + +## Figma follow-up + +Use `component-to-figma` after API review and stabilization. Create only the two component pages with red review markers. Check existing Button, QuantityField and PriceBlock composition and keep light/dark modes consistent. Do not change other components or generate new brand themes. + +LineItem examples: editable, read-only, compact, pending, unavailable, item error, long product title and custom composition with extra actions. TotalsSummary examples: partial, complete, discounted, pending and custom rows. The cart composition story demonstrates reuse; it does not define another shared component. + +## Token binding + +Bind component token aliases to existing semantic variables. Preserve the two-layer chain in each component stylesheet. Gap variables use the current Figma conventions and skill workflow. New LineItem image dimensions, title/detail typography and spacing come from existing scale tokens. TotalsSummary content/muted/discount foregrounds, divider, total weight and row/section spacing alias existing tokens. Supporting text uses the primary foreground for readable light/dark defaults; size provides its hierarchy. The existing secondary foreground has insufficient dark-mode contrast and is unchanged. + +Source of truth: + +- `src/tokens/components/organisms/_line-item.css` +- `src/tokens/components/molecules/_totals-summary.css` +- `src/organisms/line-item.tsx` +- `src/molecules/totals-summary.tsx` + +No Figma nodes or Code Connect registration are claimed in this change. Mapping follows component publication in the Figma library, independently of a GitHub merge. diff --git a/libs/ui/skills/_artifacts/domain_map.yaml b/libs/ui/skills/_artifacts/domain_map.yaml index 37d814949f..7a03403900 100644 --- a/libs/ui/skills/_artifacts/domain_map.yaml +++ b/libs/ui/skills/_artifacts/domain_map.yaml @@ -477,7 +477,8 @@ skills: toast-usage, breadcrumb-usage, tree-view-usage, skeleton-usage, and hotkeys-usage for shortcut hints and explicit-store registration, command-usage for searchable action panels, quantity-field-usage for - controlled quantity drafts with units and pending feedback, and additional entries + controlled quantity drafts with units and pending feedback, line-item-usage for + cart/order rows, totals-summary-usage for prepared amount breakdowns, and additional entries generated from libs/ui/src inventory. failure_modes: - id: "monolithic-usage-guidance" diff --git a/libs/ui/skills/_artifacts/skill_spec.md b/libs/ui/skills/_artifacts/skill_spec.md index 2e02074e54..feab639180 100644 --- a/libs/ui/skills/_artifacts/skill_spec.md +++ b/libs/ui/skills/_artifacts/skill_spec.md @@ -84,7 +84,7 @@ Total: 14 skills, 52 failure modes. | Skill | Subsystems | Reference candidates | | --- | --- | --- | -| `component-usage-ux` | per-component usage layer | `button-usage`, `input-usage`, `link-button-usage`, `dialog-usage`, `toast-usage`, `breadcrumb-usage`, `tree-view-usage`, `skeleton-usage`, `quantity-field-usage` for units/drafts/pending, plus additional usage skills generated from `libs/ui/src` inventory | +| `component-usage-ux` | per-component usage layer | `button-usage`, `input-usage`, `link-button-usage`, `dialog-usage`, `toast-usage`, `breadcrumb-usage`, `tree-view-usage`, `skeleton-usage`, `quantity-field-usage` for units/drafts/pending, `line-item-usage` for cart/order rows, `totals-summary-usage` for prepared amount breakdowns, plus additional usage skills generated from `libs/ui/src` inventory | | `framework-consumer-integration` | Next.js adapter usage | NextLink/NextImage adapter props and prop forwarding | | `hotkeys-usage`, `command-usage` | shortcuts and searchable actions | independent hints/registrations, compound Command, existing Dialog and shared application callbacks | | `zag-compound-components` | Zag-backed components | machine/connect patterns and component-specific parts | diff --git a/libs/ui/skills/_artifacts/skill_tree.yaml b/libs/ui/skills/_artifacts/skill_tree.yaml index 61b503c881..b11d6f3e80 100644 --- a/libs/ui/skills/_artifacts/skill_tree.yaml +++ b/libs/ui/skills/_artifacts/skill_tree.yaml @@ -1407,3 +1407,39 @@ skills: - src/tokens/components/organisms/_table.css - stories/organisms/table.stories.tsx - skills/_artifacts/consumer_app_usage_rules.md + + - name: line-item-usage + slug: line-item-usage + type: sub-skill + domain: consumer-app-adoption + path: skills/line-item-usage/SKILL.md + package: libs/ui + description: Compound cart and order rows with shared editable/read-only and pending guards. + requires: + - component-usage-ux + - quantity-field-usage + - price-block-usage + - app-token-overrides + - ux-guidelines + sources: + - src/organisms/line-item.tsx + - src/tokens/components/organisms/_line-item.css + - stories/organisms/line-item.stories.tsx + - docs/cart-presentation-figma-handoff.md + + - name: totals-summary-usage + slug: totals-summary-usage + type: sub-skill + domain: consumer-app-adoption + path: skills/totals-summary-usage/SKILL.md + package: libs/ui + description: Prepared amount breakdowns, unresolved fees and explicit totals without calculations. + requires: + - component-usage-ux + - app-token-overrides + - ux-guidelines + sources: + - src/molecules/totals-summary.tsx + - src/tokens/components/molecules/_totals-summary.css + - stories/molecules/totals-summary.stories.tsx + - docs/cart-presentation-figma-handoff.md diff --git a/libs/ui/skills/component-usage-ux/SKILL.md b/libs/ui/skills/component-usage-ux/SKILL.md index 49fcecd88c..85bf4c1031 100644 --- a/libs/ui/skills/component-usage-ux/SKILL.md +++ b/libs/ui/skills/component-usage-ux/SKILL.md @@ -56,6 +56,8 @@ Tree widget with arrow-key selection -> tree-view-usage Loading placeholder -> skeleton-usage Catalog search navigation -> search-suggestions-usage Catalog facet filtering -> facet-filter-panel-usage +Cart/order product row -> line-item-usage +Prepared amount breakdown and total -> totals-summary-usage Quantity with unit/help/error/pending -> quantity-field-usage Bare numeric draft or stepper -> numeric-input-usage Shortcut hint or keyboard registration -> hotkeys-usage diff --git a/libs/ui/skills/line-item-usage/SKILL.md b/libs/ui/skills/line-item-usage/SKILL.md new file mode 100644 index 0000000000..a2ef717723 --- /dev/null +++ b/libs/ui/skills/line-item-usage/SKILL.md @@ -0,0 +1,100 @@ +--- +name: line-item-usage +description: > + Use after component-usage-ux when an app needs @techsio/ui-kit LineItem + compound composition for editable cart rows, compact previews or read-only recaps. +metadata: + component_version: "1.0.0" + type: "core" + library: "@techsio/ui-kit" + library_version: "0.3.2" +requires: + - component-usage-ux + - quantity-field-usage + - price-block-usage + - app-token-overrides + - ux-guidelines +sources: + - "libs/ui/src/organisms/line-item.tsx" + - "libs/ui/src/tokens/components/organisms/_line-item.css" + - "libs/ui/stories/organisms/line-item.stories.tsx" + - "libs/ui/docs/cart-presentation-figma-handoff.md" +--- + +# @techsio/ui-kit LineItem usage + +LineItem is a compound presentation organism. Applications own DTO mapping, +formatting, routing, inventory, validation, saving and removal requests. + +## Setup + +```tsx +import { LineItem } from "@techsio/ui-kit/organisms/line-item" +import { PriceBlock } from "@techsio/ui-kit/molecules/price-block" +import { QuantityField } from "@techsio/ui-kit/molecules/quantity-field" + + + + + + + + 100 ks · pozink + + + setDraft(value)} /> + + + + + +``` + +## Compound contract + +- Root requires plain product `title` and `children`. `layout="compact"` reduces + image size and spacing. Root shares layout, readOnly and pending through context. +- Parts must render inside LineItem; otherwise they throw a descriptive error. +- `Image` wraps Image or the app's image adapter. It is optional; omit the whole + part if no image is available. The default grid uses two columns for a direct + Image child and one column without it. +- `Body`, `Heading`, `Title`, `Price`, `Options` and `Controls` arrange content. + `Title` defaults to the root title, or accepts a Link containing the same name. + `Price` accepts PriceBlock or an already formatted line amount. +- Supply AvailabilityStatus and StatusText directly where the composition needs + availability and item-level error feedback. No new engine is added for either. +- `Quantity` requires `readOnlyLabel` even in an editable composition. In readOnly + it renders that text and does not mount its editable children. In pending it + makes the editable children inert. Prefer QuantityField and pass pending to it + as well for its visual and native input state. +- Put custom mutation actions inside `Actions`. In readOnly the whole part is + omitted; pending makes its children inert. App-owned actions should also use + disabled for visual feedback. Navigation links may remain outside Actions. +- `Remove` renders the kit Button with a required visible localized `label`, adds + the root product title to its accessible name and forwards Button props/ref. + It is omitted in readOnly and disabled in pending even outside Actions. + Callers cannot override its accessible name via aria-label/aria-labelledby. +- `pending` requires localized `pendingLabel`. Root renders and associates the + feedback automatically below the composed content; no implicit live region. +- The root cannot inspect arbitrary child components. Use Quantity, Actions and + Remove for editable controls so the shared state guards apply. + +## UX/UI guidelines + +- Keep product name, options, price and quantity recognizable across contexts. +- Use compact presentation for a preview and ordinary spacing for the main cart. +- Unavailable items may remain removable. The app decides quantity restrictions. +- Keep a rejected draft visible and place its error near the relevant item. +- Preserve the supplied amounts while updating; never calculate totals here. +- Read-only recaps show quantity text and omit mutation actions. + +## Tokens and stories + +Override LineItem tokens for local presentation needs. Brand-wide changes belong +in semantic tokens. Styles use `src/tokens/components/organisms/_line-item.css`. + +Organisms/LineItem covers editable, read-only, pending, unavailable, item error, +compact/long content and CustomComposition. The custom story demonstrates an +omitted image, a relocated price and extra actions with the same shared guards. +Figma handoff follows component-to-figma after API stabilization. diff --git a/libs/ui/skills/totals-summary-usage/SKILL.md b/libs/ui/skills/totals-summary-usage/SKILL.md new file mode 100644 index 0000000000..386318aed7 --- /dev/null +++ b/libs/ui/skills/totals-summary-usage/SKILL.md @@ -0,0 +1,76 @@ +--- +name: totals-summary-usage +description: > + Use after component-usage-ux when an app needs @techsio/ui-kit TotalsSummary + to present prepared cart or checkout rows, unresolved fees, discounts, + a labeled total and actions blocked while updating. +metadata: + component_version: "1.0.0" + type: "core" + library: "@techsio/ui-kit" + library_version: "0.3.2" +requires: + - component-usage-ux + - app-token-overrides + - ux-guidelines +sources: + - "libs/ui/src/molecules/totals-summary.tsx" + - "libs/ui/src/tokens/components/molecules/_totals-summary.css" + - "libs/ui/stories/molecules/totals-summary.stories.tsx" + - "libs/ui/docs/cart-presentation-figma-handoff.md" +--- + +# @techsio/ui-kit TotalsSummary Usage + +Use TotalsSummary for a cart or checkout breakdown of already prepared labels +and values. Applications own calculations, currency formatting, tax rules, +transport/payment selection, requests and order submission. + +## Setup + +```tsx +import { TotalsSummary } from "@techsio/ui-kit/molecules/totals-summary" + + +``` + +## Contract + +- rows is a readonly array with stable unique id, label and value strings. +- Each row may have note and emphasis (default or discount). +- totalLabel and totalValue are required strings supplied by the application. +- footer and actions accept consumer content. +- Native div attributes and a div ref are supported. +- pending: true requires a localized pendingLabel. The last supplied values + remain visible, aria-busy describes the update, and the actions wrapper is inert. + Add disabled to action buttons when a disabled appearance is desired. +- The component does not create a live region. The application owns announcements + of completed requests or errors outside the busy summary. + +## UX/UI guidelines + +Load ux-guidelines and app-token-overrides for composition and appearance. +Do not represent an unselected fee as zero. Supply a clear unresolved label +and call the total a subtotal when final fees are missing. Include the tax +convention in the total label or footer. Show a discount as a separate negative +value; emphasis does not change its amount or sign. + +Use PriceBlock for one product's price. Use TotalsSummary for a labeled breakdown. +Do not pass Medusa DTOs, calculation functions or mutation hooks into the summary. +Do not use row notes or footer for mutable order controls: only the actions slot +is blocked while pending. + +## Stories + +Playground exposes labels, rows, total and pending controls. Partial preserves +unselected fees, Complete shows final fees and an action, Discounted shows a +negative discount, Pending retains amounts and blocks the checkout action, +and CustomRows demonstrates tax and deposit rows without a fixed schema. diff --git a/libs/ui/skills/ux-guidelines/references/component-selection.md b/libs/ui/skills/ux-guidelines/references/component-selection.md index 41eedd8def..964292cb98 100644 --- a/libs/ui/skills/ux-guidelines/references/component-selection.md +++ b/libs/ui/skills/ux-guidelines/references/component-selection.md @@ -80,6 +80,8 @@ Each `-usage` skill repeats its own rows in its UX/UI section. | Product image with thumbnails / zoom | `Gallery` | `Carousel` | | Horizontally browsable slides (banners, related products) | `Carousel` | `Gallery` | | Product in a listing | `ProductCard` | custom card divs | +| Product row in cart or order recap | `LineItem` | `ProductCard`, app-owned repeated row markup | +| Prepared amount breakdown and total | `TotalsSummary` | component-owned fee calculations | | Catalog facets | `FacetFilterPanel` | hand-built accordions of checkboxes | | Images with fixed ratio / framework optimisation | `Image` (`as={NextImage}`) | `` | | Decorative or adjacent icon | `Icon` | emoji, inline SVG | diff --git a/libs/ui/src/molecules/totals-summary.tsx b/libs/ui/src/molecules/totals-summary.tsx new file mode 100644 index 0000000000..acfe7680c7 --- /dev/null +++ b/libs/ui/src/molecules/totals-summary.tsx @@ -0,0 +1,112 @@ +/** + * TotalsSummary — @techsio/ui-kit molecule. + * @component TotalsSummary + * @componentVersion v1.0.0 + * @skill totals-summary-usage + * @changelog libs/ui/stories/changelog/changelog.stories.tsx + */ +import { type HTMLAttributes, type ReactNode, type Ref, useId } from "react" +import { tv } from "../utils" + +const totalsSummaryVariants = tv({ + slots: { + root: "flex min-w-0 flex-col gap-totals-summary text-totals-summary-fg text-totals-summary-size", + list: "flex min-w-0 flex-col gap-totals-summary-rows", + row: "flex min-w-0 items-baseline justify-between gap-totals-summary-columns", + label: "min-w-0 flex-1 break-words", + value: "min-w-0 flex-1 break-words text-end", + note: "block text-totals-summary-note-fg text-totals-summary-note-size", + total: + "flex min-w-0 items-baseline justify-between gap-totals-summary-columns border-t-(length:--border-width-totals-summary-divider) border-totals-summary-divider-border pt-totals-summary-total font-totals-summary-total text-totals-summary-total-size", + footer: "text-totals-summary-note-fg text-totals-summary-note-size", + actions: "flex flex-col gap-totals-summary-actions", + pending: "text-totals-summary-note-fg text-totals-summary-note-size", + }, + variants: { + emphasis: { + default: { row: "" }, + discount: { row: "text-totals-summary-discount-fg" }, + }, + }, + defaultVariants: { emphasis: "default" }, +}) + +export type TotalsSummaryRow = { + id: string + label: string + value: string + emphasis?: "default" | "discount" + note?: string +} + +export type TotalsSummaryProps = Omit< + HTMLAttributes, + "children" +> & { + rows: readonly TotalsSummaryRow[] + totalLabel: string + totalValue: string + footer?: ReactNode + actions?: ReactNode + ref?: Ref +} & ( + | { pending: true; pendingLabel: string } + | { pending?: false; pendingLabel?: string } + ) + +export function TotalsSummary({ + rows, + totalLabel, + totalValue, + footer, + actions, + pending, + pendingLabel, + className, + "aria-describedby": describedBy, + ...props +}: TotalsSummaryProps) { + const pendingId = useId() + const styles = totalsSummaryVariants() + return ( +
+
+ {rows.map(({ id, label, value, emphasis, note }) => ( +
+
{label}
+
+ {value} + {note ? {note} : null} +
+
+ ))} +
+
{totalLabel}
+
{totalValue}
+
+
+ {pending ? ( +

+ {pendingLabel} +

+ ) : null} + {footer ?
{footer}
: null} + {actions ? ( +
+ {actions} +
+ ) : null} +
+ ) +} diff --git a/libs/ui/src/organisms/line-item.tsx b/libs/ui/src/organisms/line-item.tsx new file mode 100644 index 0000000000..8447e7d9c4 --- /dev/null +++ b/libs/ui/src/organisms/line-item.tsx @@ -0,0 +1,279 @@ +/** + * LineItem — @techsio/ui-kit organism. + * @component LineItem + * @componentVersion v1.0.0 + * @skill line-item-usage + * @changelog libs/ui/stories/changelog/changelog.stories.tsx + */ +import { + createContext, + type HTMLAttributes, + type ReactNode, + type Ref, + useContext, + useId, +} from "react" +import { Button, type ButtonProps } from "../atoms/button" +import { StatusText } from "../atoms/status-text" +import { tv } from "../utils" + +const lineItemVariants = tv({ + slots: { + root: "grid min-w-0 gap-line-item text-line-item-content-fg has-[>[data-part=image]]:grid-cols-line-item", + image: + "size-line-item-image overflow-hidden rounded-line-item-image [&>img]:size-full [&>img]:object-contain", + body: "flex min-w-0 flex-col gap-line-item-content", + heading: "flex min-w-0 flex-wrap items-start gap-line-item-heading", + title: + "min-w-0 break-words font-line-item-title text-line-item-title-size [[data-part=heading]>&]:flex-1 [[data-part=heading]>&]:basis-line-item-title-min-width", + price: "min-w-0 max-w-full", + options: "break-words text-line-item-options-fg text-line-item-detail-size", + controls: "flex min-w-0 flex-wrap items-end gap-line-item-controls", + quantity: "min-w-0 max-w-full flex-1 basis-line-item-quantity-width", + quantityLabel: "break-words text-line-item-detail-size", + actions: "flex flex-wrap items-center gap-line-item-controls", + pending: "col-span-full text-line-item-content-fg", + }, + variants: { + layout: { + default: {}, + compact: { + image: "size-line-item-image-compact", + root: "gap-line-item-compact", + }, + }, + }, + defaultVariants: { layout: "default" }, +}) + +type LineItemContextValue = { + title: string + readOnly: boolean + pending: boolean + styles: ReturnType +} +const LineItemContext = createContext(null) +function useLineItemContext() { + const context = useContext(LineItemContext) + if (!context) + throw new Error("LineItem parts must be rendered inside LineItem") + return context +} + +export type LineItemPartProps = HTMLAttributes & { + ref?: Ref +} +export type LineItemQuantityProps = LineItemPartProps & { + readOnlyLabel: string +} +export type LineItemRemoveProps = Omit< + ButtonProps, + "children" | "aria-label" | "aria-labelledby" | "type" +> & { + label: string + children?: never + "aria-label"?: never + "aria-labelledby"?: never +} +export type LineItemProps = Omit< + HTMLAttributes, + "title" | "children" +> & { + title: string + children: ReactNode + readOnly?: boolean + layout?: "default" | "compact" + ref?: Ref +} & ( + | { pending: true; pendingLabel: string } + | { pending?: false; pendingLabel?: string } + ) + +export function LineItem({ + title, + children, + readOnly = false, + pending = false, + pendingLabel, + layout = "default", + className, + ref, + "aria-describedby": ariaDescribedBy, + ...props +}: LineItemProps) { + const pendingId = useId() + const describedBy = + [ariaDescribedBy, pending ? pendingId : undefined] + .filter(Boolean) + .join(" ") || undefined + const styles = lineItemVariants({ layout }) + return ( + +
+ {children} + {pending ? ( + + {pendingLabel} + + ) : null} +
+
+ ) +} + +LineItem.Image = function LineItemImage({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Body = function LineItemBody({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Heading = function LineItemHeading({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Title = function LineItemTitle({ + className, + children, + ...props +}: LineItemPartProps) { + const { title, styles } = useLineItemContext() + return ( +
+ {children ?? title} +
+ ) +} +LineItem.Price = function LineItemPrice({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Options = function LineItemOptions({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Controls = function LineItemControls({ + className, + ...props +}: LineItemPartProps) { + const { styles } = useLineItemContext() + return ( +
+ ) +} +LineItem.Quantity = function LineItemQuantity({ + readOnlyLabel, + children, + className, + ...props +}: LineItemQuantityProps) { + const { styles, readOnly, pending } = useLineItemContext() + return readOnly ? ( +
+ {readOnlyLabel} +
+ ) : ( +
+ {children} +
+ ) +} +LineItem.Actions = function LineItemActions({ + className, + ...props +}: LineItemPartProps) { + const { styles, readOnly, pending } = useLineItemContext() + if (readOnly) return null + return ( +
+ ) +} +LineItem.Remove = function LineItemRemove({ + label, + onClick, + disabled, + size = "sm", + theme = "borderless", + ...props +}: LineItemRemoveProps) { + const { title, readOnly, pending } = useLineItemContext() + if (readOnly) return null + return ( + + ) +} diff --git a/libs/ui/src/tokens/components/components.css b/libs/ui/src/tokens/components/components.css index 1a37a8ba1b..f97790ebf1 100644 --- a/libs/ui/src/tokens/components/components.css +++ b/libs/ui/src/tokens/components/components.css @@ -35,6 +35,7 @@ @import "./molecules/_popover.css"; @import "./molecules/_price-block.css"; @import "./molecules/_quantity-field.css"; +@import "./molecules/_totals-summary.css"; @import "./molecules/_radio-card.css"; @import "./molecules/_radio-group.css"; @import "./molecules/_menu.css"; @@ -55,6 +56,7 @@ @import "./organisms/_footer.css"; @import "./organisms/_header.css"; @import "./organisms/_gallery.css"; +@import "./organisms/_line-item.css"; @import "./organisms/_sidebar.css"; @import "./templates/_facet-filter-panel.css"; diff --git a/libs/ui/src/tokens/components/molecules/_totals-summary.css b/libs/ui/src/tokens/components/molecules/_totals-summary.css new file mode 100644 index 0000000000..eb4816cb51 --- /dev/null +++ b/libs/ui/src/tokens/components/molecules/_totals-summary.css @@ -0,0 +1,25 @@ +@theme static { + /* Reference layer */ + --color-totals-summary-content: var(--color-fg-primary); + --color-totals-summary-muted: var(--color-fg-primary); + --color-totals-summary-positive: var(--color-success-fg); + --color-totals-summary-divider: var(--color-border-secondary); + --spacing-totals-summary-related: var(--spacing-150); + --spacing-totals-summary-section: var(--spacing-250); + + /* Derived layer */ + --color-totals-summary-fg: var(--color-totals-summary-content); + --color-totals-summary-note-fg: var(--color-totals-summary-muted); + --color-totals-summary-discount-fg: var(--color-totals-summary-positive); + --color-totals-summary-divider-border: var(--color-totals-summary-divider); + --border-width-totals-summary-divider: var(--border-width-sm); + --gap-totals-summary: var(--spacing-totals-summary-section); + --gap-totals-summary-rows: var(--spacing-totals-summary-related); + --gap-totals-summary-columns: var(--spacing-totals-summary-related); + --gap-totals-summary-actions: var(--spacing-totals-summary-related); + --padding-totals-summary-total: var(--spacing-totals-summary-related); + --text-totals-summary-size: var(--text-sm); + --text-totals-summary-note-size: var(--text-xs); + --text-totals-summary-total-size: var(--text-lg); + --font-weight-totals-summary-total: var(--font-weight-semibold); +} diff --git a/libs/ui/src/tokens/components/organisms/_line-item.css b/libs/ui/src/tokens/components/organisms/_line-item.css new file mode 100644 index 0000000000..4fd4093aa3 --- /dev/null +++ b/libs/ui/src/tokens/components/organisms/_line-item.css @@ -0,0 +1,31 @@ +@theme static { + /* Reference layer */ + --color-line-item-content: var(--color-fg-primary); + --color-line-item-muted: var(--color-fg-primary); + --spacing-line-item-related: var(--spacing-150); + --spacing-line-item-separated: var(--spacing-200); + --spacing-line-item-thumbnail: var(--spacing-850); + --spacing-line-item-thumbnail-compact: var(--spacing-700); + + /* Derived layer */ + --color-line-item-content-fg: var(--color-line-item-content); + --color-line-item-options-fg: var(--color-line-item-muted); + --gap-line-item: var(--spacing-line-item-separated); + --gap-line-item-compact: var(--spacing-line-item-related); + --gap-line-item-content: var(--spacing-line-item-related); + --gap-line-item-heading: var(--spacing-line-item-related); + --gap-line-item-controls: var(--spacing-line-item-related); + --spacing-line-item-image: var(--spacing-line-item-thumbnail); + --spacing-line-item-image-compact: var(--spacing-line-item-thumbnail-compact); + --spacing-line-item-title-min-width: var(--spacing-950); + --spacing-line-item-quantity-width: calc(var(--spacing-950) * 2); + --grid-template-columns-line-item: auto minmax(0, 1fr); + --radius-line-item-image: var(--radius-sm); + --text-line-item-title-size: var(--text-md); + --text-line-item-detail-size: var(--text-sm); + --font-weight-line-item-title: var(--font-weight-medium); +} + +@utility grid-cols-line-item { + grid-template-columns: var(--grid-template-columns-line-item); +} diff --git a/libs/ui/stories/changelog/changelog.stories.tsx b/libs/ui/stories/changelog/changelog.stories.tsx index eb427a3227..ac32cfc5e7 100644 --- a/libs/ui/stories/changelog/changelog.stories.tsx +++ b/libs/ui/stories/changelog/changelog.stories.tsx @@ -12,6 +12,12 @@ import type { Meta, StoryObj } from "@storybook/react" const CHANGELOG = ` ## @techsio/ui-kit — Unreleased +### LineItem v1.0.0 +- Added a compound cart and order row with composable image/title/price/quantity/actions parts, shared read-only/pending guards, product-specific removal labels and compact layout. Requests and inventory rules remain consumer-owned. + +### TotalsSummary v1.0.0 +- Added prepared label/value rows, notes, discounts, an explicit total and pending feedback. Unresolved fees remain caller-provided text; the component does not calculate totals. + ### QuantityField v1.0.0 - Added a controlled quantity molecule with a visible unit, linked label/helper/error text and localized pending feedback over NumericInput. Validation, persistence and explicit removal stay consumer-owned. - autoFocus, aria-label and aria-labelledby reach the editable input; native label and description bindings remain intact. diff --git a/libs/ui/stories/molecules/totals-summary.stories.tsx b/libs/ui/stories/molecules/totals-summary.stories.tsx new file mode 100644 index 0000000000..1d84b7eebc --- /dev/null +++ b/libs/ui/stories/molecules/totals-summary.stories.tsx @@ -0,0 +1,166 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { fn } from "storybook/test" +import { VariantContainer, VariantGroup } from "../../.storybook/decorator" +import { Button } from "../../src/atoms/button" +import { + TotalsSummary, + type TotalsSummaryRow, +} from "../../src/molecules/totals-summary" + +const checkout = fn() +const completeRows: readonly TotalsSummaryRow[] = [ + { id: "goods", label: "Zboží", value: "1 498 Kč" }, + { + id: "shipping", + label: "Doprava", + value: "89 Kč", + note: "Doručení na adresu", + }, + { id: "payment", label: "Platba", value: "Zdarma", note: "Kartou online" }, +] +const partialRows: readonly TotalsSummaryRow[] = [ + { id: "goods", label: "Zboží", value: "1 498 Kč" }, + { id: "shipping", label: "Doprava", value: "Zvolte dopravu" }, + { id: "payment", label: "Platba", value: "Zvolte platbu" }, +] + +const meta: Meta = { + title: "Molecules/TotalsSummary", + component: TotalsSummary, + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: + "Rozpis hotových popisků a částek. Aplikace určuje řádky i výsledný součet; nevybraná doprava není nula. Pending zachová poslední částky a zablokuje akce.", + }, + }, + }, + args: { + rows: completeRows, + totalLabel: "Celkem včetně DPH", + totalValue: "1 587 Kč", + pendingLabel: "Aktualizujeme souhrn objednávky", + footer: "Částky jsou uvedeny včetně DPH.", + }, + argTypes: { + rows: { control: "object" }, + totalLabel: { control: "text" }, + totalValue: { control: "text" }, + pending: { control: "boolean" }, + pendingLabel: { control: "text" }, + footer: { control: "text" }, + actions: { control: false }, + ref: { control: false }, + }, +} +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: (args) => ( +
+ + Pokračovat k objednávce + + } + /> +
+ ), +} + +export const Partial: Story = { + name: "Částečný souhrn — doprava ještě není vybraná", + args: { + rows: partialRows, + totalLabel: "Mezisoučet včetně DPH", + totalValue: "1 498 Kč", + footer: "Konečná cena bude dostupná po výběru dopravy a platby.", + }, + render: (args) => ( +
+ +
+ ), +} + +export const Complete: Story = { + name: "Kompletní souhrn — vybraná doprava a platba", + render: (args) => ( +
+ Pokračovat k objednávce} + /> +
+ ), +} + +export const Discounted: Story = { + name: "Sleva — samostatný záporný řádek", + args: { + rows: [ + ...completeRows, + { + id: "discount", + label: "Sleva VITEJTE", + value: "−150 Kč", + emphasis: "discount", + note: "Platný slevový kód", + }, + ], + totalValue: "1 437 Kč", + }, + render: (args) => ( +
+ +
+ ), +} + +export const Pending: Story = { + name: "Aktualizace — poslední částky a zablokovaná akce", + args: { pending: true, pendingLabel: "Aktualizujeme souhrn objednávky" }, + render: (args) => ( +
+ + Pokračovat k objednávce + + } + /> +
+ ), +} + +export const CustomRows: Story = { + name: "Vlastní řádky — bez předepsaných poplatků a výpočtů", + render: () => ( + + +
+ +
+
+
+ ), +} diff --git a/libs/ui/stories/organisms/line-item.stories.tsx b/libs/ui/stories/organisms/line-item.stories.tsx new file mode 100644 index 0000000000..0a61228e1a --- /dev/null +++ b/libs/ui/stories/organisms/line-item.stories.tsx @@ -0,0 +1,260 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { useState } from "react" +import { fn } from "storybook/test" +import { VariantContainer, VariantGroup } from "../../.storybook/decorator" +import { Button } from "../../src/atoms/button" +import { Image } from "../../src/atoms/image" +import { Link } from "../../src/atoms/link" +import { StatusText } from "../../src/atoms/status-text" +import { AvailabilityStatus } from "../../src/molecules/availability-status" +import { PriceBlock } from "../../src/molecules/price-block" +import { QuantityField } from "../../src/molecules/quantity-field" +import { LineItem, type LineItemProps } from "../../src/organisms/line-item" + +const removed = fn() +const quantityChanged = fn() +const saved = fn() +const productTitle = "Montážní sada M8" +const longProductTitle = + "Montážní sada pro připevnění těžkých polic a regálů v dílně" +const thumbnail = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%23999' stroke='%23444' stroke-width='4' d='M25 15h50l20 35-20 35H25L5 50z'/%3E%3Ccircle cx='50' cy='50' r='20' fill='%23eee' stroke='%23444' stroke-width='4'/%3E%3C/svg%3E" + +function DraftQuantity({ + title, + pending = false, + disabled = false, +}: { + title: string + pending?: boolean + disabled?: boolean +}) { + const [value, setValue] = useState("2") + return ( + { + setValue(details.value) + quantityChanged(details) + }} + pending={pending} + pendingLabel="Ukládáme množství" + disabled={disabled} + size="sm" + unitLabel="balení" + /> + ) +} + +type CartRowProps = ( + | Omit, "children"> + | Omit, "children"> +) & { unavailable?: boolean; error?: string } + +function CartRow({ unavailable = false, error, ...props }: CartRowProps) { + return ( + + + + + + + + {props.title} + + + + + + 100 ks · pozink · kód MS-008 + {unavailable ? ( + + ) : null} + + + + + + + + + {error ? {error} : null} + + + ) +} + +const meta: Meta = { + title: "Organisms/LineItem", + component: LineItem, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Compound řádek produktu pro košík a rekapitulaci. Aplikace skládá části a dodává cenu, quantity control a dostupnost. Root sdílí pending/readOnly; Quantity v readOnly nezapojí své editovatelné děti a Actions/Remove se nevykreslí. Žádné výpočty nebo requesty.", + }, + }, + }, + args: { + title: productTitle, + layout: "default", + readOnly: false, + pendingLabel: "Ukládáme položku", + }, + argTypes: { + title: { control: "text" }, + layout: { control: "select", options: ["default", "compact"] }, + readOnly: { control: "boolean" }, + pending: { control: "boolean" }, + pendingLabel: { control: "text" }, + children: { control: false }, + ref: { control: false }, + }, +} +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +} +export const ReadOnly: Story = { + args: { readOnly: true }, + render: (args) => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: + "Stejná compound skladba obsahuje QuantityField i Remove. Root readOnly ale quantity nahradí textem a akce úplně vynechá.", + }, + }, + }, +} +export const Pending: Story = { + args: { pending: true, pendingLabel: "Ukládáme položku" }, + render: (args) => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: + "Root blokuje Quantity a Actions pomocí inert, Remove navíc deaktivuje. QuantityField dostává pending také pro viditelný stav.", + }, + }, + }, +} +export const Unavailable: Story = { + render: (args) => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: + "Aplikace označí položku jako nedostupnou a deaktivuje quantity. Odebrání zůstává dostupné.", + }, + }, + }, +} +export const ItemError: Story = { + render: (args) => ( +
+ +
+ ), +} +export const Layouts: Story = { + render: (args) => ( + + {(["default", "compact"] as const).map((layout) => ( + +
+ +
+
+ ))} +
+ ), +} +export const LongContent: Story = { + args: { title: longProductTitle, layout: "compact" }, + render: (args) => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: + "Dlouhý název na úzkém viewportu: cena a ovládání zůstávají viditelné.", + }, + }, + }, +} +export const CustomComposition: Story = { + render: (args) => ( +
+ + + + + Bez fotografie, cena pod názvem a vlastní dodatečná akce. + + + + + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: + "Compound API umožňuje vynechat obrázek, přesunout cenu a doplnit vlastní akce. Přepněte pending/readOnly v Controls: společné pojistky platí i pro vlastní akce.", + }, + }, + }, +} diff --git a/libs/ui/stories/templates/cart-presentation.stories.tsx b/libs/ui/stories/templates/cart-presentation.stories.tsx new file mode 100644 index 0000000000..151ecdf003 --- /dev/null +++ b/libs/ui/stories/templates/cart-presentation.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { useState } from "react" +import { fn } from "storybook/test" +import { VariantContainer, VariantGroup } from "../../.storybook/decorator" +import { Button } from "../../src/atoms/button" +import { Link } from "../../src/atoms/link" +import { PriceBlock } from "../../src/molecules/price-block" +import { QuantityField } from "../../src/molecules/quantity-field" +import { TotalsSummary } from "../../src/molecules/totals-summary" +import { LineItem } from "../../src/organisms/line-item" + +const checkout = fn() +const remove = fn() +const quantityChange = fn() + +function CartExample({ readOnly = false }: { readOnly?: boolean }) { + const [quantity, setQuantity] = useState("2") + const title = "Montážní sada M8" + return ( +
+ + + + + {title} + + + + + + 100 ks · pozink · kód MS-008 + + + { + setQuantity(details.value) + quantityChange(details) + }} + size="sm" + unitLabel="balení" + value={quantity} + /> + + + + + + + + Pokračovat k objednávce + ) + } + /> +
+ ) +} + +const meta: Meta = { + title: "Templates/CartPresentation", + component: CartExample, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Kompozice LineItem, QuantityField, PriceBlock a TotalsSummary pro košík a rekapitulaci. Ukázka neobsahuje výpočty, síťové požadavky ani chování minikošíku.", + }, + }, + }, + args: { readOnly: false }, + argTypes: { readOnly: { control: "boolean" } }, +} +export default meta +type Story = StoryObj + +export const Playground: Story = {} +export const CartAndRecap: Story = { + render: () => ( + + + + + + + + + ), +} diff --git a/libs/ui/test/line-item.spec.ts b/libs/ui/test/line-item.spec.ts new file mode 100644 index 0000000000..68bbfc79ac --- /dev/null +++ b/libs/ui/test/line-item.spec.ts @@ -0,0 +1,89 @@ +import { expect, type Page, test } from "@playwright/test" + +async function openStory(page: Page, story: string, args?: string) { + const query = new URLSearchParams({ + id: `organisms-lineitem--${story}`, + viewMode: "story", + }) + if (args) query.set("args", args) + await page.goto(`/iframe.html?${query}`) + await expect(page.getByText("498 Kč", { exact: true })).toBeVisible() +} + +test("read-only recap contains quantity text without mutable controls", async ({ + page, +}) => { + await openStory(page, "read-only") + await expect(page.getByText("2 balení", { exact: true })).toBeVisible() + await expect(page.getByRole("spinbutton")).toHaveCount(0) + await expect(page.getByRole("button")).toHaveCount(0) +}) + +test("pending prevents removal and quantity interaction", async ({ page }) => { + await openStory(page, "pending") + await expect(page.locator('[data-part="remove"]')).toBeDisabled() + const input = page.locator('input[role="spinbutton"]') + await expect(input).toBeDisabled() + await expect(input.locator("xpath=ancestor::*[@inert]")).toHaveCount(1) + await expect( + page.getByText("Ukládáme položku", { exact: true }), + ).toBeVisible() +}) + +test("unavailable items remain removable by a product-specific accessible action", async ({ + page, +}) => { + await openStory(page, "unavailable") + await expect(page.getByText("Není skladem", { exact: true })).toBeVisible() + await expect( + page.getByRole("button", { name: "Odebrat: Montážní sada M8" }), + ).toBeEnabled() +}) + +test("a long title does not push the amount out of a narrow viewport", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 900 }) + await openStory(page, "long-content") + const amount = await page.getByText("498 Kč", { exact: true }).boundingBox() + if (!amount) { + throw new Error("The line amount must be visible") + } + expect(amount.x + amount.width).toBeLessThanOrEqual(375) + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(375) +}) + +test("custom compound actions follow root pending and read-only", async ({ + page, +}) => { + await openStory(page, "custom-composition") + const title = await page.locator('[data-part="title"]').boundingBox() + const options = await page.locator('[data-part="options"]').boundingBox() + if (!title || !options) throw new Error("The custom content must be visible") + expect(options.y - (title.y + title.height)).toBeLessThan(32) + await openStory(page, "custom-composition", "pending:true") + const action = page.locator('[data-part="actions"] button').first() + await expect(action).toBeDisabled() + await action.evaluate((button) => { + button.removeAttribute("disabled") + button.addEventListener("click", () => + button.setAttribute("data-clicked", "true"), + ) + button.focus() + }) + await expect(action).not.toBeFocused() + const bounds = await action.boundingBox() + if (!bounds) throw new Error("The custom action must remain visible") + await page.mouse.click( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2, + ) + await expect(action).not.toHaveAttribute("data-clicked", "true") + await openStory(page, "custom-composition", "readOnly:true") + await expect(page.getByText("2 balení", { exact: true })).toBeVisible() + await expect(page.locator('[data-part="actions"]')).toHaveCount(0) + await expect(page.getByRole("spinbutton")).toHaveCount(0) + await expect(page.getByRole("button")).toHaveCount(0) +}) diff --git a/libs/ui/test/totals-summary.spec.ts b/libs/ui/test/totals-summary.spec.ts new file mode 100644 index 0000000000..e83031a2a6 --- /dev/null +++ b/libs/ui/test/totals-summary.spec.ts @@ -0,0 +1,91 @@ +import { expect, type Page, test } from "@playwright/test" + +async function openStory(page: Page, story: string) { + await page.goto( + `/iframe.html?id=molecules-totalssummary--${story}&viewMode=story`, + ) + await expect(page.locator('[data-scope="totals-summary"]')).toBeVisible() +} + +test("unselected fees stay distinct from zero and the app supplies the subtotal", async ({ + page, +}) => { + await openStory(page, "partial") + const summary = page.locator('[data-scope="totals-summary"]') + await expect( + summary.getByText("Zvolte dopravu", { exact: true }), + ).toBeVisible() + await expect( + summary.getByText("Zvolte platbu", { exact: true }), + ).toBeVisible() + await expect( + summary.getByText("Mezisoučet včetně DPH", { exact: true }), + ).toBeVisible() + await expect(summary.getByText("0 Kč", { exact: true })).toHaveCount(0) +}) + +test("discount and arbitrary tax/deposit rows retain their prepared values", async ({ + page, +}) => { + await openStory(page, "discounted") + await expect( + page.getByRole("definition").filter({ hasText: "−150 Kč" }), + ).toBeVisible() + await expect(page.getByText("1 437 Kč", { exact: true })).toBeVisible() + await openStory(page, "custom-rows") + await expect(page.getByText("DPH 21 %", { exact: true })).toBeVisible() + await expect( + page.getByText("Vratná záloha za obal", { exact: true }), + ).toBeVisible() + await expect(page.getByText("1 310 Kč", { exact: true })).toBeVisible() +}) + +test("pending retains the last summary and blocks even an enabled action", async ({ + page, +}) => { + await openStory(page, "pending") + const summary = page.locator('[data-scope="totals-summary"]') + await expect(summary).toHaveAttribute("aria-busy", "true") + await expect( + summary.getByText("Aktualizujeme souhrn objednávky", { exact: true }), + ).toBeVisible() + await expect(summary.getByText("1 587 Kč", { exact: true })).toBeVisible() + await expect(summary.locator("[inert]")).toHaveCount(1) + await expect(summary.locator("button")).toBeDisabled() + await summary + .locator("button") + .evaluate((button) => button.removeAttribute("disabled")) + const action = summary.locator("button") + await expect(action).toBeEnabled() + await action.evaluate((button) => { + button.addEventListener("click", () => + button.setAttribute("data-clicked", "true"), + ) + button.focus() + }) + await expect(action).not.toBeFocused() + const bounds = await action.boundingBox() + if (!bounds) throw new Error("The pending action must remain visible") + await page.mouse.click( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2, + ) + await expect(action).not.toHaveAttribute("data-clicked", "true") +}) + +test("complete summary exposes term/value relationships and fits mobile", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 812 }) + await openStory(page, "complete") + await expect( + page.getByRole("term").filter({ hasText: "Celkem včetně DPH" }), + ).toBeVisible() + await expect( + page.getByRole("definition").filter({ hasText: "1 587 Kč" }), + ).toBeVisible() + const overflow = await page + .locator('[data-scope="totals-summary"]') + .evaluate((element) => element.scrollWidth > element.clientWidth) + expect(overflow).toBe(false) +}) diff --git a/libs/ui/tests/line-item.types.test.ts b/libs/ui/tests/line-item.types.test.ts new file mode 100644 index 0000000000..35328f07e2 --- /dev/null +++ b/libs/ui/tests/line-item.types.test.ts @@ -0,0 +1,67 @@ +import { type ComponentProps, createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server" +import { describe, expect, expectTypeOf, it } from "vitest" +import { LineItem, type LineItemProps } from "../src/organisms/line-item" + +describe("LineItem compound contract", () => { + it("requires localized pending copy and a product name", () => { + const ready: LineItemProps = { title: "Product", children: null } + // @ts-expect-error Pending requires a localized explanation. + const unlabeledPending: LineItemProps = { ...ready, pending: true } + // @ts-expect-error Product name is required for removal naming. + const unnamed: LineItemProps = { children: null } + expectTypeOf(ready).toExtend() + expectTypeOf(unlabeledPending).toExtend() + expectTypeOf(unnamed).toExtend() + }) + it("requires readable quantity text and a localized remove label", () => { + type QuantityProps = ComponentProps + type RemoveProps = ComponentProps + expectTypeOf().toEqualTypeOf() + // @ts-expect-error A quantity part needs a read-only fallback. + const missingQuantityLabel: QuantityProps = { children: null } + // @ts-expect-error Removal needs a visible localized label. + const missingRemoveLabel: RemoveProps = { onClick: () => undefined } + const replacedRemoveLabel: RemoveProps = { + label: "Remove", + // @ts-expect-error Caller cannot replace the product-specific accessible name. + "aria-label": "Delete", + } + expectTypeOf(missingQuantityLabel).toExtend() + expectTypeOf(missingRemoveLabel).toExtend() + expectTypeOf(replacedRemoveLabel).toExtend() + }) + it("omits editable children and actions when the root is read-only", () => { + const html = renderToStaticMarkup( + createElement(LineItem, { + title: "Product", + readOnly: true, + children: [ + createElement( + LineItem.Quantity, + { key: "quantity", readOnlyLabel: "2 pieces" }, + createElement("input", { "aria-label": "Quantity" }), + ), + createElement( + LineItem.Actions, + { key: "actions" }, + createElement("button", { type: "button" }, "Save"), + ), + createElement(LineItem.Remove, { + key: "remove", + label: "Remove", + onClick: () => undefined, + }), + ], + }), + ) + expect(html).toContain("2 pieces") + expect(html).not.toContain(" { + expect(() => renderToStaticMarkup(createElement(LineItem.Title))).toThrow( + "LineItem parts must be rendered inside LineItem", + ) + }) +}) diff --git a/libs/ui/tests/totals-summary.types.test.ts b/libs/ui/tests/totals-summary.types.test.ts new file mode 100644 index 0000000000..02509e5155 --- /dev/null +++ b/libs/ui/tests/totals-summary.types.test.ts @@ -0,0 +1,31 @@ +import { describe, expectTypeOf, it } from "vitest" +import type { + TotalsSummaryProps, + TotalsSummaryRow, +} from "../src/molecules/totals-summary" + +describe("TotalsSummary presentation contract", () => { + it("accepts readonly prepared rows and formatted totals", () => { + expectTypeOf().toEqualTypeOf< + readonly TotalsSummaryRow[] + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + it("requires localized text while pending", () => { + const ready: TotalsSummaryProps = { + rows: [], + totalLabel: "Total", + totalValue: "Choose shipping", + } + const pending: TotalsSummaryProps = { + ...ready, + pending: true, + pendingLabel: "Updating total", + } + // @ts-expect-error An updating summary requires a localized explanation. + const missingLabel: TotalsSummaryProps = { ...ready, pending: true } + expectTypeOf(pending).toExtend() + expectTypeOf(missingLabel).toExtend() + }) +}) From 56c8c43b4ac1fb2ab0fbc169ed1d978637b3474d Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 12 Oct 2026 10:06:10 +0200 Subject: [PATCH 2/5] docs(ui): add cart presentation Figma handoff and mappings --- .../docs/cart-presentation-figma-handoff.md | 19 ++++++-- libs/ui/src/molecules/totals-summary.figma.ts | 42 +++++++++++++++++ libs/ui/src/organisms/line-item.figma.ts | 47 +++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 libs/ui/src/molecules/totals-summary.figma.ts create mode 100644 libs/ui/src/organisms/line-item.figma.ts diff --git a/libs/ui/docs/cart-presentation-figma-handoff.md b/libs/ui/docs/cart-presentation-figma-handoff.md index c3bbcede7f..b22b57cd7f 100644 --- a/libs/ui/docs/cart-presentation-figma-handoff.md +++ b/libs/ui/docs/cart-presentation-figma-handoff.md @@ -7,9 +7,14 @@ LineItem and TotalsSummary implement audit C01/C02. Both are presentational; app - LineItem is a compound organism. Root shares title, layout, pending and readOnly. Image, Body, Heading, Title, Price, Options and Controls arrange application-provided content. Quantity requires readOnlyLabel and replaces editable children with text in readOnly. Actions omits mutation controls in readOnly and blocks them while pending. Remove uses a localized label containing the root product name, is omitted in readOnly and disabled while pending. Root pending feedback is automatic. AvailabilityStatus and StatusText can be inserted directly; image, price and actions can be omitted or rearranged. - TotalsSummary is a molecule. It renders supplied label/value rows and notes in a definition list, a separately labeled total, optional footer and actions. Pending preserves the supplied values and blocks actions. An unresolved fee must be supplied as text such as `Choose delivery`, not assumed to be zero. -## Figma follow-up +## Figma component pages -Use `component-to-figma` after API review and stabilization. Create only the two component pages with red review markers. Check existing Button, QuantityField and PriceBlock composition and keep light/dark modes consistent. Do not change other components or generate new brand themes. +Created through direct Figma MCP using `component-to-figma`: + +- [LineItem review page](https://www.figma.com/design/gi5GUSWwAeXknaKEeLqK5w/New-Design-System?node-id=3901-173), page `3898:2`, set `3900:594`, eight layout/readOnly/pending variants. +- [TotalsSummary review page](https://www.figma.com/design/gi5GUSWwAeXknaKEeLqK5w/New-Design-System?node-id=3902-34), page `3898:3`, set `3899:92`, two pending variants. Private row set `3899:25` represents supplied data, not another React component API. + +Both pages carry red review markers. Existing pages, component masters, variable values and modes were preserved. Nested PriceBlock, QuantityField, Button, AvailabilityStatus and StatusText are instances. QuantityField instances clear their inherited forced Light mode. Local button paint overrides avoid stale remote bindings inside these new components. LineItem examples: editable, read-only, compact, pending, unavailable, item error, long product title and custom composition with extra actions. TotalsSummary examples: partial, complete, discounted, pending and custom rows. The cart composition story demonstrates reuse; it does not define another shared component. @@ -24,4 +29,12 @@ Source of truth: - `src/organisms/line-item.tsx` - `src/molecules/totals-summary.tsx` -No Figma nodes or Code Connect registration are claimed in this change. Mapping follows component publication in the Figma library, independently of a GitHub merge. +New `line-item` and `totals-summary` collections contain aliases, including reference-to-derived chains. Two missing computed success foreground primitives live in a new `core` collection and are aliased by `semantic/color/success/fg` in Light/Dark. Preview panels switch this semantic collection together with the existing Theme. No existing token collections were reorganized. Figma uses the existing library Inter font; browser system-ui can wrap differently. Fluid image/title/quantity dimensions use 96/120/240 px design references while code retains its clamp expressions. + +## Verification and Code Connect + +Full review sheets were visually inspected against Storybook. Row notes grow their containers, pending feedback does not overlap, and review sections have no overlap or horizontal overflow. The custom composition moves price below options and supplies an additional outlined action. The item error follows controls. These two composition presets are private static documentation components; the public configurable set remains LineItem. + +Local parserless templates are `src/organisms/line-item.figma.ts` and `src/molecules/totals-summary.figma.ts`. `pnpm --dir libs/ui figma:connect:parse` passed with both templates included. This validates parsing, not live template execution in Figma. TotalsSummary converts configurable row helpers to the `rows` data prop; footer/actions remain slots. LineItem maps its root variants and composition slots to the public compound API. + +Figma rejected both registration attempts: "Published component not found. Please make sure component/component set is published before mapping." Code Connect remains unregistered. Publish the component sets to the Figma team library, then register the templates. Library publication is independent of a GitHub merge. Nested dependency mappings must also be available for their slot snippets to resolve fully. diff --git a/libs/ui/src/molecules/totals-summary.figma.ts b/libs/ui/src/molecules/totals-summary.figma.ts new file mode 100644 index 0000000000..e3fad0034c --- /dev/null +++ b/libs/ui/src/molecules/totals-summary.figma.ts @@ -0,0 +1,42 @@ +// url=https://www.figma.com/design/gi5GUSWwAeXknaKEeLqK5w/New-Design-System?node-id=3899-92 +// source=https://github.com/TechsioCZ/new-engine/blob/feat/ui-kit-cart-presentation/libs/ui/src/molecules/totals-summary.tsx +// component=TotalsSummary + +import figma from "figma" + +const instance = figma.selectedInstance +const pending = instance.getEnum("pending", { false: false, true: true }) +const pendingLabel = instance.getString("pendingLabel") +const totalLabel = instance.getString("totalLabel") +const totalValue = instance.getString("totalValue") +// Figma row helpers represent data, rather than a React component or JSX slot. +const rows = instance + .findLayers((node) => node.type === "INSTANCE" && node.name === "Row", { + path: ["List", "rows"], + }) + .filter((node) => node.type === "INSTANCE") + .map((row, index) => ({ + id: `row-${index + 1}`, + label: row.getString("label"), + value: row.getString("value"), + emphasis: row.getEnum("emphasis", { + default: "default", + discount: "discount", + }), + note: row.getBoolean("showNote") ? row.getString("note") : undefined, + })) +const footer = instance.getBoolean("showFooter") ? instance.getSlot("footer") : [] +const actions = instance.getBoolean("showActions") + ? instance.getSlot("actions") + : [] + +export default { + id: "TotalsSummary", + imports: [ + 'import { TotalsSummary } from "@techsio/ui-kit/molecules/totals-summary"', + ], + example: figma.code`${footer}}` : ""}${actions?.length ? figma.code` actions={<>${actions}}` : ""} +/>`, + metadata: { nestable: false }, +} diff --git a/libs/ui/src/organisms/line-item.figma.ts b/libs/ui/src/organisms/line-item.figma.ts new file mode 100644 index 0000000000..26112e1be1 --- /dev/null +++ b/libs/ui/src/organisms/line-item.figma.ts @@ -0,0 +1,47 @@ +// url=https://www.figma.com/design/gi5GUSWwAeXknaKEeLqK5w/New-Design-System?node-id=3900-594 +// source=https://github.com/TechsioCZ/new-engine/blob/feat/ui-kit-cart-presentation/libs/ui/src/organisms/line-item.tsx +// component=LineItem + +import figma from "figma" + +const instance = figma.selectedInstance +const title = instance.getString("title") +const layout = instance.getEnum("layout", { + default: "default", + compact: "compact", +}) +const readOnly = instance.getEnum("readOnly", { false: false, true: true }) +const pending = instance.getEnum("pending", { false: false, true: true }) +const pendingLabel = instance.getString("pendingLabel") +const readOnlyLabel = instance.getString("readOnlyLabel") +const image = instance.getBoolean("showImage") ? instance.getSlot("Image") : [] +const price = instance.getBoolean("showPrice") ? instance.getSlot("Price") : [] +const options = instance.getBoolean("showOptions") + ? instance.getString("options") + : undefined +const feedback = instance.getBoolean("showFeedback") + ? instance.getSlot("Feedback") + : [] +const quantity = instance.getSlot("Quantity") +const actions = instance.getSlot("Actions") + +export default { + id: "LineItem", + imports: ['import { LineItem } from "@techsio/ui-kit/organisms/line-item"'], + example: figma.code` + ${image?.length ? figma.code`${image}` : ""} + + + + ${price?.length ? figma.code`${price}` : ""} + + ${options !== undefined ? figma.code`{${JSON.stringify(options)}}` : ""} + ${feedback} + + ${quantity} + ${!readOnly && actions?.length ? figma.code`${actions}` : ""} + + +`, + metadata: { nestable: false }, +} From 023817e74572220b5f85fe95b2db9ee5d6af54a4 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 12 Oct 2026 10:12:42 +0200 Subject: [PATCH 3/5] fix(ui): mark LineItem as a client module --- libs/ui/agent-plugin/skills/line-item-usage/SKILL.md | 7 ++++++- libs/ui/docs/cart-presentation-figma-handoff.md | 6 +++++- libs/ui/skills/line-item-usage/SKILL.md | 7 ++++++- libs/ui/src/organisms/line-item.tsx | 6 ++++-- libs/ui/stories/changelog/changelog.stories.tsx | 3 +++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md index fb5a005184..8121358f82 100644 --- a/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md @@ -4,7 +4,7 @@ description: > Use after component-usage-ux when an app needs @techsio/ui-kit LineItem compound composition for editable cart rows, compact previews or read-only recaps. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" @@ -17,6 +17,11 @@ metadata: LineItem is a compound presentation organism. Applications own DTO mapping, formatting, routing, inventory, validation, saving and removal requests. +LineItem uses React context and is a client module. In Next.js, compose the root +and its compound parts inside a wrapper with `"use client"`, including read-only +order recaps. A Server Component can render that wrapper with serializable data; +do not access `LineItem.Body` or other compound properties in a Server Component. + ## Setup ```tsx diff --git a/libs/ui/docs/cart-presentation-figma-handoff.md b/libs/ui/docs/cart-presentation-figma-handoff.md index b22b57cd7f..2cf87f4f11 100644 --- a/libs/ui/docs/cart-presentation-figma-handoff.md +++ b/libs/ui/docs/cart-presentation-figma-handoff.md @@ -1,9 +1,13 @@ -# PR10 cart presentation: Figma handoff +# PR10 cart presentation: Figma handoff LineItem and TotalsSummary implement audit C01/C02. Both are presentational; application formatting, shipping selection, inventory, totals and mutations remain application-owned. ## Component ownership +LineItem is a client module because its compound parts share React context. +Next.js consumers compose the whole row inside a `"use client"` wrapper, even +for read-only recaps. Server Components pass serializable data to that wrapper. + - LineItem is a compound organism. Root shares title, layout, pending and readOnly. Image, Body, Heading, Title, Price, Options and Controls arrange application-provided content. Quantity requires readOnlyLabel and replaces editable children with text in readOnly. Actions omits mutation controls in readOnly and blocks them while pending. Remove uses a localized label containing the root product name, is omitted in readOnly and disabled while pending. Root pending feedback is automatic. AvailabilityStatus and StatusText can be inserted directly; image, price and actions can be omitted or rearranged. - TotalsSummary is a molecule. It renders supplied label/value rows and notes in a definition list, a separately labeled total, optional footer and actions. Pending preserves the supplied values and blocks actions. An unresolved fee must be supplied as text such as `Choose delivery`, not assumed to be zero. diff --git a/libs/ui/skills/line-item-usage/SKILL.md b/libs/ui/skills/line-item-usage/SKILL.md index a2ef717723..4475c63e1d 100644 --- a/libs/ui/skills/line-item-usage/SKILL.md +++ b/libs/ui/skills/line-item-usage/SKILL.md @@ -4,7 +4,7 @@ description: > Use after component-usage-ux when an app needs @techsio/ui-kit LineItem compound composition for editable cart rows, compact previews or read-only recaps. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" @@ -26,6 +26,11 @@ sources: LineItem is a compound presentation organism. Applications own DTO mapping, formatting, routing, inventory, validation, saving and removal requests. +LineItem uses React context and is a client module. In Next.js, compose the root +and its compound parts inside a wrapper with `"use client"`, including read-only +order recaps. A Server Component can render that wrapper with serializable data; +do not access `LineItem.Body` or other compound properties in a Server Component. + ## Setup ```tsx diff --git a/libs/ui/src/organisms/line-item.tsx b/libs/ui/src/organisms/line-item.tsx index 8447e7d9c4..05e364a5c9 100644 --- a/libs/ui/src/organisms/line-item.tsx +++ b/libs/ui/src/organisms/line-item.tsx @@ -1,7 +1,9 @@ -/** +"use client" + +/** * LineItem — @techsio/ui-kit organism. * @component LineItem - * @componentVersion v1.0.0 + * @componentVersion v1.0.1 * @skill line-item-usage * @changelog libs/ui/stories/changelog/changelog.stories.tsx */ diff --git a/libs/ui/stories/changelog/changelog.stories.tsx b/libs/ui/stories/changelog/changelog.stories.tsx index 637d818093..d324fb0f83 100644 --- a/libs/ui/stories/changelog/changelog.stories.tsx +++ b/libs/ui/stories/changelog/changelog.stories.tsx @@ -12,6 +12,9 @@ import type { Meta, StoryObj } from "@storybook/react" const CHANGELOG = ` ## @techsio/ui-kit — Unreleased +### LineItem v1.0.1 +- Mark the compound row as a client module for Next.js and document client-wrapper composition for read-only recaps. + ### LineItem v1.0.0 - Added a compound cart and order row with composable image/title/price/quantity/actions parts, shared read-only/pending guards, product-specific removal labels and compact layout. Requests and inventory rules remain consumer-owned. From 292b244e38b82fa3e8e6808691c65377910965f2 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 12 Oct 2026 10:23:52 +0200 Subject: [PATCH 4/5] fix(ui): accept removal button refs in LineItem --- .../skills/line-item-usage/SKILL.md | 2 +- libs/ui/skills/line-item-usage/SKILL.md | 2 +- libs/ui/src/organisms/line-item.tsx | 3 ++- .../stories/changelog/changelog.stories.tsx | 3 +++ libs/ui/tests/line-item.types.test.ts | 20 ++++++++++++++++++- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md index 8121358f82..0faa0f10e9 100644 --- a/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/line-item-usage/SKILL.md @@ -4,7 +4,7 @@ description: > Use after component-usage-ux when an app needs @techsio/ui-kit LineItem compound composition for editable cart rows, compact previews or read-only recaps. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/skills/line-item-usage/SKILL.md b/libs/ui/skills/line-item-usage/SKILL.md index 4475c63e1d..47dd8845b4 100644 --- a/libs/ui/skills/line-item-usage/SKILL.md +++ b/libs/ui/skills/line-item-usage/SKILL.md @@ -4,7 +4,7 @@ description: > Use after component-usage-ux when an app needs @techsio/ui-kit LineItem compound composition for editable cart rows, compact previews or read-only recaps. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/src/organisms/line-item.tsx b/libs/ui/src/organisms/line-item.tsx index 05e364a5c9..81c3b1fa69 100644 --- a/libs/ui/src/organisms/line-item.tsx +++ b/libs/ui/src/organisms/line-item.tsx @@ -3,7 +3,7 @@ /** * LineItem — @techsio/ui-kit organism. * @component LineItem - * @componentVersion v1.0.1 + * @componentVersion v1.0.2 * @skill line-item-usage * @changelog libs/ui/stories/changelog/changelog.stories.tsx */ @@ -73,6 +73,7 @@ export type LineItemRemoveProps = Omit< "children" | "aria-label" | "aria-labelledby" | "type" > & { label: string + ref?: Ref children?: never "aria-label"?: never "aria-labelledby"?: never diff --git a/libs/ui/stories/changelog/changelog.stories.tsx b/libs/ui/stories/changelog/changelog.stories.tsx index d324fb0f83..5239a6d529 100644 --- a/libs/ui/stories/changelog/changelog.stories.tsx +++ b/libs/ui/stories/changelog/changelog.stories.tsx @@ -12,6 +12,9 @@ import type { Meta, StoryObj } from "@storybook/react" const CHANGELOG = ` ## @techsio/ui-kit — Unreleased +### LineItem v1.0.2 +- Accept object and callback refs on the removal button after the Button props update. + ### LineItem v1.0.1 - Mark the compound row as a client module for Next.js and document client-wrapper composition for read-only recaps. diff --git a/libs/ui/tests/line-item.types.test.ts b/libs/ui/tests/line-item.types.test.ts index 35328f07e2..03498276bd 100644 --- a/libs/ui/tests/line-item.types.test.ts +++ b/libs/ui/tests/line-item.types.test.ts @@ -1,9 +1,27 @@ -import { type ComponentProps, createElement } from "react" +import { type ComponentProps, createElement, createRef, type Ref } from "react" import { renderToStaticMarkup } from "react-dom/server" import { describe, expect, expectTypeOf, it } from "vitest" import { LineItem, type LineItemProps } from "../src/organisms/line-item" describe("LineItem compound contract", () => { + it("accepts object and callback refs for the removal button", () => { + type RemoveProps = ComponentProps + const objectRef: RemoveProps = { + label: "Remove", + ref: createRef(), + } + const callbackRef: RemoveProps = { + label: "Remove", + ref: (button) => { + expectTypeOf(button).toEqualTypeOf() + }, + } + expectTypeOf().toEqualTypeOf< + Ref | undefined + >() + expectTypeOf(objectRef).toExtend() + expectTypeOf(callbackRef).toExtend() + }) it("requires localized pending copy and a product name", () => { const ready: LineItemProps = { title: "Product", children: null } // @ts-expect-error Pending requires a localized explanation. From 981ebd9ae91d2461722443ad8fd7a0e746716374 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 12 Oct 2026 10:42:51 +0200 Subject: [PATCH 5/5] style(ui): align cart follow-up with current formatter --- libs/ui/src/molecules/totals-summary.figma.ts | 48 +- libs/ui/src/organisms/line-item.figma.ts | 22 +- libs/ui/src/organisms/line-item.tsx | 433 +++++++++--------- libs/ui/tests/line-item.types.test.ts | 157 +++---- 4 files changed, 335 insertions(+), 325 deletions(-) diff --git a/libs/ui/src/molecules/totals-summary.figma.ts b/libs/ui/src/molecules/totals-summary.figma.ts index e3fad0034c..517ef17eb3 100644 --- a/libs/ui/src/molecules/totals-summary.figma.ts +++ b/libs/ui/src/molecules/totals-summary.figma.ts @@ -11,32 +11,34 @@ const totalLabel = instance.getString("totalLabel") const totalValue = instance.getString("totalValue") // Figma row helpers represent data, rather than a React component or JSX slot. const rows = instance - .findLayers((node) => node.type === "INSTANCE" && node.name === "Row", { - path: ["List", "rows"], - }) - .filter((node) => node.type === "INSTANCE") - .map((row, index) => ({ - id: `row-${index + 1}`, - label: row.getString("label"), - value: row.getString("value"), - emphasis: row.getEnum("emphasis", { - default: "default", - discount: "discount", - }), - note: row.getBoolean("showNote") ? row.getString("note") : undefined, - })) -const footer = instance.getBoolean("showFooter") ? instance.getSlot("footer") : [] + .findLayers((node) => node.type === "INSTANCE" && node.name === "Row", { + path: ["List", "rows"], + }) + .filter((node) => node.type === "INSTANCE") + .map((row, index) => ({ + id: `row-${index + 1}`, + label: row.getString("label"), + value: row.getString("value"), + emphasis: row.getEnum("emphasis", { + default: "default", + discount: "discount", + }), + note: row.getBoolean("showNote") ? row.getString("note") : undefined, + })) +const footer = instance.getBoolean("showFooter") + ? instance.getSlot("footer") + : [] const actions = instance.getBoolean("showActions") - ? instance.getSlot("actions") - : [] + ? instance.getSlot("actions") + : [] export default { - id: "TotalsSummary", - imports: [ - 'import { TotalsSummary } from "@techsio/ui-kit/molecules/totals-summary"', - ], - example: figma.code`${footer}}` : ""}${actions?.length ? figma.code` actions={<>${actions}}` : ""} />`, - metadata: { nestable: false }, + metadata: { nestable: false }, } diff --git a/libs/ui/src/organisms/line-item.figma.ts b/libs/ui/src/organisms/line-item.figma.ts index 26112e1be1..4af1169bbf 100644 --- a/libs/ui/src/organisms/line-item.figma.ts +++ b/libs/ui/src/organisms/line-item.figma.ts @@ -7,8 +7,8 @@ import figma from "figma" const instance = figma.selectedInstance const title = instance.getString("title") const layout = instance.getEnum("layout", { - default: "default", - compact: "compact", + default: "default", + compact: "compact", }) const readOnly = instance.getEnum("readOnly", { false: false, true: true }) const pending = instance.getEnum("pending", { false: false, true: true }) @@ -17,25 +17,25 @@ const readOnlyLabel = instance.getString("readOnlyLabel") const image = instance.getBoolean("showImage") ? instance.getSlot("Image") : [] const price = instance.getBoolean("showPrice") ? instance.getSlot("Price") : [] const options = instance.getBoolean("showOptions") - ? instance.getString("options") - : undefined + ? instance.getString("options") + : undefined const feedback = instance.getBoolean("showFeedback") - ? instance.getSlot("Feedback") - : [] + ? instance.getSlot("Feedback") + : [] const quantity = instance.getSlot("Quantity") const actions = instance.getSlot("Actions") export default { - id: "LineItem", - imports: ['import { LineItem } from "@techsio/ui-kit/organisms/line-item"'], - example: figma.code` + id: "LineItem", + imports: ['import { LineItem } from "@techsio/ui-kit/organisms/line-item"'], + example: figma.code` ${image?.length ? figma.code`${image}` : ""} ${price?.length ? figma.code`${price}` : ""} - ${options !== undefined ? figma.code`{${JSON.stringify(options)}}` : ""} + ${options === undefined ? "" : figma.code`{${JSON.stringify(options)}}`} ${feedback} ${quantity} @@ -43,5 +43,5 @@ export default { `, - metadata: { nestable: false }, + metadata: { nestable: false }, } diff --git a/libs/ui/src/organisms/line-item.tsx b/libs/ui/src/organisms/line-item.tsx index 81c3b1fa69..5b6203cdc8 100644 --- a/libs/ui/src/organisms/line-item.tsx +++ b/libs/ui/src/organisms/line-item.tsx @@ -8,275 +8,282 @@ * @changelog libs/ui/stories/changelog/changelog.stories.tsx */ import { - createContext, - type HTMLAttributes, - type ReactNode, - type Ref, - useContext, - useId, + createContext, + type HTMLAttributes, + type ReactNode, + type Ref, + useContext, + useId, } from "react" import { Button, type ButtonProps } from "../atoms/button" import { StatusText } from "../atoms/status-text" import { tv } from "../utils" const lineItemVariants = tv({ - slots: { - root: "grid min-w-0 gap-line-item text-line-item-content-fg has-[>[data-part=image]]:grid-cols-line-item", - image: - "size-line-item-image overflow-hidden rounded-line-item-image [&>img]:size-full [&>img]:object-contain", - body: "flex min-w-0 flex-col gap-line-item-content", - heading: "flex min-w-0 flex-wrap items-start gap-line-item-heading", - title: - "min-w-0 break-words font-line-item-title text-line-item-title-size [[data-part=heading]>&]:flex-1 [[data-part=heading]>&]:basis-line-item-title-min-width", - price: "min-w-0 max-w-full", - options: "break-words text-line-item-options-fg text-line-item-detail-size", - controls: "flex min-w-0 flex-wrap items-end gap-line-item-controls", - quantity: "min-w-0 max-w-full flex-1 basis-line-item-quantity-width", - quantityLabel: "break-words text-line-item-detail-size", - actions: "flex flex-wrap items-center gap-line-item-controls", - pending: "col-span-full text-line-item-content-fg", - }, - variants: { - layout: { - default: {}, - compact: { - image: "size-line-item-image-compact", - root: "gap-line-item-compact", - }, - }, - }, - defaultVariants: { layout: "default" }, + slots: { + root: "grid min-w-0 gap-line-item text-line-item-content-fg has-[>[data-part=image]]:grid-cols-line-item", + image: + "size-line-item-image overflow-hidden rounded-line-item-image [&>img]:size-full [&>img]:object-contain", + body: "flex min-w-0 flex-col gap-line-item-content", + heading: "flex min-w-0 flex-wrap items-start gap-line-item-heading", + title: + "min-w-0 break-words font-line-item-title text-line-item-title-size [[data-part=heading]>&]:flex-1 [[data-part=heading]>&]:basis-line-item-title-min-width", + price: "min-w-0 max-w-full", + options: "break-words text-line-item-detail-size text-line-item-options-fg", + controls: "flex min-w-0 flex-wrap items-end gap-line-item-controls", + quantity: "min-w-0 max-w-full flex-1 basis-line-item-quantity-width", + quantityLabel: "break-words text-line-item-detail-size", + actions: "flex flex-wrap items-center gap-line-item-controls", + pending: "col-span-full text-line-item-content-fg", + }, + variants: { + layout: { + default: {}, + compact: { + image: "size-line-item-image-compact", + root: "gap-line-item-compact", + }, + }, + }, + defaultVariants: { layout: "default" }, }) type LineItemContextValue = { - title: string - readOnly: boolean - pending: boolean - styles: ReturnType + title: string + readOnly: boolean + pending: boolean + styles: ReturnType } const LineItemContext = createContext(null) function useLineItemContext() { - const context = useContext(LineItemContext) - if (!context) - throw new Error("LineItem parts must be rendered inside LineItem") - return context + const context = useContext(LineItemContext) + if (!context) { + throw new Error("LineItem parts must be rendered inside LineItem") + } + return context } export type LineItemPartProps = HTMLAttributes & { - ref?: Ref + ref?: Ref } export type LineItemQuantityProps = LineItemPartProps & { - readOnlyLabel: string + readOnlyLabel: string } export type LineItemRemoveProps = Omit< - ButtonProps, - "children" | "aria-label" | "aria-labelledby" | "type" + ButtonProps, + "children" | "aria-label" | "aria-labelledby" | "type" > & { - label: string - ref?: Ref - children?: never - "aria-label"?: never - "aria-labelledby"?: never + label: string + ref?: Ref + children?: never + "aria-label"?: never + "aria-labelledby"?: never } export type LineItemProps = Omit< - HTMLAttributes, - "title" | "children" + HTMLAttributes, + "title" | "children" > & { - title: string - children: ReactNode - readOnly?: boolean - layout?: "default" | "compact" - ref?: Ref + title: string + children: ReactNode + readOnly?: boolean + layout?: "default" | "compact" + ref?: Ref } & ( - | { pending: true; pendingLabel: string } - | { pending?: false; pendingLabel?: string } - ) + | { pending: true; pendingLabel: string } + | { pending?: false; pendingLabel?: string } + ) export function LineItem({ - title, - children, - readOnly = false, - pending = false, - pendingLabel, - layout = "default", - className, - ref, - "aria-describedby": ariaDescribedBy, - ...props + title, + children, + readOnly = false, + pending = false, + pendingLabel, + layout = "default", + className, + ref, + "aria-describedby": ariaDescribedBy, + ...props }: LineItemProps) { - const pendingId = useId() - const describedBy = - [ariaDescribedBy, pending ? pendingId : undefined] - .filter(Boolean) - .join(" ") || undefined - const styles = lineItemVariants({ layout }) - return ( - -
- {children} - {pending ? ( - - {pendingLabel} - - ) : null} -
-
- ) + const pendingId = useId() + const describedBy = + [ariaDescribedBy, pending ? pendingId : undefined] + .filter(Boolean) + .join(" ") || undefined + const styles = lineItemVariants({ layout }) + return ( + +
+ {children} + {pending ? ( + + {pendingLabel} + + ) : null} +
+
+ ) } LineItem.Image = function LineItemImage({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Body = function LineItemBody({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Heading = function LineItemHeading({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Title = function LineItemTitle({ - className, - children, - ...props + className, + children, + ...props }: LineItemPartProps) { - const { title, styles } = useLineItemContext() - return ( -
- {children ?? title} -
- ) + const { title, styles } = useLineItemContext() + return ( +
+ {children ?? title} +
+ ) } LineItem.Price = function LineItemPrice({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Options = function LineItemOptions({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Controls = function LineItemControls({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles } = useLineItemContext() - return ( -
- ) + const { styles } = useLineItemContext() + return ( +
+ ) } LineItem.Quantity = function LineItemQuantity({ - readOnlyLabel, - children, - className, - ...props + readOnlyLabel, + children, + className, + ...props }: LineItemQuantityProps) { - const { styles, readOnly, pending } = useLineItemContext() - return readOnly ? ( -
- {readOnlyLabel} -
- ) : ( -
- {children} -
- ) + const { styles, readOnly, pending } = useLineItemContext() + return readOnly ? ( +
+ {readOnlyLabel} +
+ ) : ( +
+ {children} +
+ ) } LineItem.Actions = function LineItemActions({ - className, - ...props + className, + ...props }: LineItemPartProps) { - const { styles, readOnly, pending } = useLineItemContext() - if (readOnly) return null - return ( -
- ) + const { styles, readOnly, pending } = useLineItemContext() + if (readOnly) { + return null + } + return ( +
+ ) } LineItem.Remove = function LineItemRemove({ - label, - onClick, - disabled, - size = "sm", - theme = "borderless", - ...props + label, + onClick, + disabled, + size = "sm", + theme = "borderless", + ...props }: LineItemRemoveProps) { - const { title, readOnly, pending } = useLineItemContext() - if (readOnly) return null - return ( - - ) + const { title, readOnly, pending } = useLineItemContext() + if (readOnly) { + return null + } + return ( + + ) } diff --git a/libs/ui/tests/line-item.types.test.ts b/libs/ui/tests/line-item.types.test.ts index 03498276bd..861a9c2d68 100644 --- a/libs/ui/tests/line-item.types.test.ts +++ b/libs/ui/tests/line-item.types.test.ts @@ -4,82 +4,83 @@ import { describe, expect, expectTypeOf, it } from "vitest" import { LineItem, type LineItemProps } from "../src/organisms/line-item" describe("LineItem compound contract", () => { - it("accepts object and callback refs for the removal button", () => { - type RemoveProps = ComponentProps - const objectRef: RemoveProps = { - label: "Remove", - ref: createRef(), - } - const callbackRef: RemoveProps = { - label: "Remove", - ref: (button) => { - expectTypeOf(button).toEqualTypeOf() - }, - } - expectTypeOf().toEqualTypeOf< - Ref | undefined - >() - expectTypeOf(objectRef).toExtend() - expectTypeOf(callbackRef).toExtend() - }) - it("requires localized pending copy and a product name", () => { - const ready: LineItemProps = { title: "Product", children: null } - // @ts-expect-error Pending requires a localized explanation. - const unlabeledPending: LineItemProps = { ...ready, pending: true } - // @ts-expect-error Product name is required for removal naming. - const unnamed: LineItemProps = { children: null } - expectTypeOf(ready).toExtend() - expectTypeOf(unlabeledPending).toExtend() - expectTypeOf(unnamed).toExtend() - }) - it("requires readable quantity text and a localized remove label", () => { - type QuantityProps = ComponentProps - type RemoveProps = ComponentProps - expectTypeOf().toEqualTypeOf() - // @ts-expect-error A quantity part needs a read-only fallback. - const missingQuantityLabel: QuantityProps = { children: null } - // @ts-expect-error Removal needs a visible localized label. - const missingRemoveLabel: RemoveProps = { onClick: () => undefined } - const replacedRemoveLabel: RemoveProps = { - label: "Remove", - // @ts-expect-error Caller cannot replace the product-specific accessible name. - "aria-label": "Delete", - } - expectTypeOf(missingQuantityLabel).toExtend() - expectTypeOf(missingRemoveLabel).toExtend() - expectTypeOf(replacedRemoveLabel).toExtend() - }) - it("omits editable children and actions when the root is read-only", () => { - const html = renderToStaticMarkup( - createElement(LineItem, { - title: "Product", - readOnly: true, - children: [ - createElement( - LineItem.Quantity, - { key: "quantity", readOnlyLabel: "2 pieces" }, - createElement("input", { "aria-label": "Quantity" }), - ), - createElement( - LineItem.Actions, - { key: "actions" }, - createElement("button", { type: "button" }, "Save"), - ), - createElement(LineItem.Remove, { - key: "remove", - label: "Remove", - onClick: () => undefined, - }), - ], - }), - ) - expect(html).toContain("2 pieces") - expect(html).not.toContain(" { - expect(() => renderToStaticMarkup(createElement(LineItem.Title))).toThrow( - "LineItem parts must be rendered inside LineItem", - ) - }) + it("accepts object and callback refs for the removal button", () => { + type RemoveProps = ComponentProps + const objectRef: RemoveProps = { + label: "Remove", + ref: createRef(), + } + const callbackRef: RemoveProps = { + label: "Remove", + ref: (button) => { + expectTypeOf(button).toEqualTypeOf() + }, + } + expectTypeOf().toEqualTypeOf< + Ref | undefined + >() + expectTypeOf(objectRef).toExtend() + expectTypeOf(callbackRef).toExtend() + }) + it("requires localized pending copy and a product name", () => { + const ready: LineItemProps = { title: "Product", children: null } + // @ts-expect-error Pending requires a localized explanation. + const unlabeledPending: LineItemProps = { ...ready, pending: true } + // @ts-expect-error Product name is required for removal naming. + const unnamed: LineItemProps = { children: null } + expectTypeOf(ready).toExtend() + expectTypeOf(unlabeledPending).toExtend() + expectTypeOf(unnamed).toExtend() + }) + it("requires readable quantity text and a localized remove label", () => { + type QuantityProps = ComponentProps + type RemoveProps = ComponentProps + expectTypeOf().toEqualTypeOf() + // @ts-expect-error A quantity part needs a read-only fallback. + const missingQuantityLabel: QuantityProps = { children: null } + // @ts-expect-error Removal needs a visible localized label. + const missingRemoveLabel: RemoveProps = { onClick: () => undefined } + const replacedRemoveLabel: RemoveProps = { + label: "Remove", + // @ts-expect-error Caller cannot replace the product-specific accessible name. + "aria-label": "Delete", + } + expectTypeOf(missingQuantityLabel).toExtend() + expectTypeOf(missingRemoveLabel).toExtend() + expectTypeOf(replacedRemoveLabel).toExtend() + }) + it("omits editable children and actions when the root is read-only", () => { + const html = renderToStaticMarkup( + createElement(LineItem, { + title: "Product", + readOnly: true, + // biome-ignore lint/correctness/noChildrenProp: createElement must satisfy LineItemProps, which requires children. + children: [ + createElement( + LineItem.Quantity, + { key: "quantity", readOnlyLabel: "2 pieces" }, + createElement("input", { "aria-label": "Quantity" }) + ), + createElement( + LineItem.Actions, + { key: "actions" }, + createElement("button", { type: "button" }, "Save") + ), + createElement(LineItem.Remove, { + key: "remove", + label: "Remove", + onClick: () => undefined, + }), + ], + }) + ) + expect(html).toContain("2 pieces") + expect(html).not.toContain(" { + expect(() => renderToStaticMarkup(createElement(LineItem.Title))).toThrow( + "LineItem parts must be rendered inside LineItem" + ) + }) })