From 7a72ee55e8bca6c0bbcb3820f176098e698d3004 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:31:20 +0000 Subject: [PATCH 01/12] docs: Show a cart's totals in the computed properties Query demo The Global computations demo is now a cart on the demo layout: one Query joins each CartItem's quantity with its Product's price into the count, subtotal, shipping and total, read by both the header's badge and the order summary. Changing a quantity moves both; the demo plays itself once in view. The design system gains a quantity stepper, and stats labels stay in line when a figure is plain text. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/computed-properties.md | 208 ++++++++-- .../references/computed-properties.vue.md | 195 +++++++-- docs/rest/guides/computed-properties.md | 371 ++++++++++++++---- .../Playground/DesignSystem/design-system.css | 60 +++ website/src/components/Playground/README.md | 1 + website/src/fixtures/cart.ts | 81 ++++ 6 files changed, 785 insertions(+), 131 deletions(-) create mode 100644 website/src/fixtures/cart.ts diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index 250eba16c032..a932624e0e93 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -66,49 +66,191 @@ class User extends Entity { ## Global computations -[Query](./Query.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. +[Query](./Query.md) derives data from many entities at once, such as a count or a total +(an aggregate). Define it once, beside your resources, and read it from any component with +[useQuery](https://dataclient.io/docs/api/useQuery). -```ts title="resources/User" +Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge +shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read +`getCartTotals`; change a quantity and both follow. + +```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; +} + +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, + +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, }); + +export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```tsx title="UsersPage" -import { All, Query, schema } from '@data-client/rest'; -import { useQuery, useSuspense } from '@data-client/react'; -import { UserResource, User } from './resources/User'; - -const getUserCount = new Query( - new All(User), - (entries, { isAdmin } = {}) => { - if (isAdmin !== undefined) - return entries.filter(user => user.isAdmin === isAdmin).length; - return entries.length; - }, -); - -function UsersPage() { - useSuspense(UserResource.getList); - const userCount = useQuery(getUserCount); - const adminCount = useQuery(getUserCount, { isAdmin: true }); - // this should never happen since we suspense but typescript does not know that - if (userCount === undefined) return null; +```ts title="getCartTotals" {4-17} +import { All, Query } from '@data-client/rest'; +import { CartItem } from './resources/Cart'; + +export const getCartTotals = new Query(new All(CartItem), items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const freeShippingAt = 50; + const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; +}); +``` + +```tsx title="CartBadge" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CartBadge() { + const totals = useQuery(getCartTotals); + if (!totals) return null; return ( -
-
Total users: {userCount}
-
Total admins: {adminCount}
-
+ + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} + + ); } -render(); ``` + +```tsx title="OrderSummary" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function OrderSummary() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + <> +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} +

+
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
+ + ); +} +``` + +```tsx title="CartPage" +import { useController, useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CartBadge from './CartBadge'; +import OrderSummary from './OrderSummary'; +import { CartItem, CartResource, usd } from './resources/Cart'; + +function CartPage() { + const items = useSuspense(CartResource.getList); + const ctrl = useController(); + const setQuantity = (item: CartItem, quantity: number) => + quantity ? + ctrl.fetch(CartResource.partialUpdate, { id: item.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: item.id }); + + return ( + <> +
+

Daily Grind

+ +
+

Cart

+
+ {items.map(item => ( +
+
+ {item.product.name} +
+ +
+
+ + + {item.quantity} + + + + + +
+ ))} +
+ + + ); +} +render(); +``` + +Computing these totals in a component instead would mean fetching both kinds of data +there, repeating the math wherever totals show, or passing them down as props. With a +`Query`: + +- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each + other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one + `useQuery()` call. +- **Computed once per change.** `useQuery()` results are memoized globally against the entities + they read. However many components read `getCartTotals`, it runs once when a `CartItem` or + `Product` changes, and otherwise returns the same object. +- **Always current.** Any change to an item, like this demo's [optimistic](https://dataclient.io/rest/api/resource#optimistic) + quantity updates, re-derives every total at once, with nothing to invalidate or sync. diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md index 981281d729fb..673c1de21fc0 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.vue.md +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -66,51 +66,188 @@ class User extends Entity { ## Global computations -[Query](./Query.vue.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. +[Query](./Query.vue.md) derives data from many entities at once, such as a count or a total +(an aggregate). Define it once, beside your resources, and read it from any component with +[useQuery](https://dataclient.io/vue/api/useQuery). -```ts title="resources/User" +Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge +shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read +`getCartTotals`; change a quantity and both follow. + +```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; +} + +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, + +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, +}); + +export const usd = { style: 'currency', currency: 'USD' } as const; +``` + +```ts title="getCartTotals" {4-17} +import { All, Query } from '@data-client/rest'; +import { CartItem } from './resources/Cart'; + +export const getCartTotals = new Query(new All(CartItem), items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const freeShippingAt = 50; + const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; }); ``` -```html title="UsersPage.vue" - + +``` + +```html title="OrderSummary.vue" +``` + +```html title="CartPage.vue" + + + ``` + +Computing these totals in a component instead would mean fetching both kinds of data +there, repeating the math wherever totals show, or passing them down as props. With a +`Query`: + +- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each + other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one + `useQuery()` call. +- **Computed once per change.** `useQuery()` results are memoized globally against the entities + they read. However many components read `getCartTotals`, it runs once when a `CartItem` or + `Product` changes, and otherwise returns the same object. +- **Always current.** Any change to an item, like this demo's [optimistic](https://dataclient.io/rest/api/resource#optimistic) + quantity updates, re-derives every total at once, with nothing to invalidate or sync. diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index 7ff7586d2ded..60297696cb26 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -2,8 +2,20 @@ title: Computed Properties --- -import { All, Query, RestEndpoint } from '@data-client/rest'; import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; +import { + cartFixtures, + getInitialInterceptorData, +} from '@site/src/fixtures/cart'; + +export const cartDemo = [ + // $48 to $52: free shipping, and the total drops + { delay: 1200, click: 'button[aria-label="Add one Paper filters"]' }, + { wait: 2200 }, + { click: 'button[aria-label="Remove one House blend"]' }, + { wait: 2200 }, + { click: 'button[aria-label="Add one House blend"]' }, +]; ## Singular computations @@ -75,104 +87,325 @@ class User extends Entity { ## Global computations -[Query](../api/Query.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. - - - -```ts title="resources/User" collapsed +[Query](../api/Query.md) derives data from many entities at once, such as a count or a total +(an aggregate). Define it once, beside your resources, and read it from any component with +[useQuery](/docs/api/useQuery). + +Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge +shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read +`getCartTotals`; change a quantity and both follow. + + + +```ts title="resources/Cart" collapsed import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; +} + +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, + +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, +}); + +export const usd = { style: 'currency', currency: 'USD' } as const; +``` + +```ts title="getCartTotals" {4-17} +import { All, Query } from '@data-client/rest'; +import { CartItem } from './resources/Cart'; + +export const getCartTotals = new Query(new All(CartItem), items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const freeShippingAt = 50; + const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; }); ``` :::react -```tsx title="UsersPage" -import { All, Query, schema } from '@data-client/rest'; -import { useQuery, useSuspense } from '@data-client/react'; -import { UserResource, User } from './resources/User'; - -const getUserCount = new Query( - new All(User), - (entries, { isAdmin } = {}) => { - if (isAdmin !== undefined) - return entries.filter(user => user.isAdmin === isAdmin).length; - return entries.length; - }, -); - -function UsersPage() { - useSuspense(UserResource.getList); - const userCount = useQuery(getUserCount); - const adminCount = useQuery(getUserCount, { isAdmin: true }); - // this should never happen since we suspense but typescript does not know that - if (userCount === undefined) return null; +```tsx title="CartBadge" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CartBadge() { + const totals = useQuery(getCartTotals); + if (!totals) return null; return ( -
-
Total users: {userCount}
-
Total admins: {adminCount}
-
+ + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} + + + ); +} +``` + +```tsx title="OrderSummary" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function OrderSummary() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + <> +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} +

+
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
+ ); } -render(); +``` + +```tsx title="CartPage" collapsed +import { useController, useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CartBadge from './CartBadge'; +import OrderSummary from './OrderSummary'; +import { CartItem, CartResource, usd } from './resources/Cart'; + +function CartPage() { + const items = useSuspense(CartResource.getList); + const ctrl = useController(); + const setQuantity = (item: CartItem, quantity: number) => + quantity ? + ctrl.fetch(CartResource.partialUpdate, { id: item.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: item.id }); + + return ( + <> +
+

Daily Grind

+ +
+

Cart

+
+ {items.map(item => ( +
+
+ {item.product.name} +
+ +
+
+ + + {item.quantity} + + + + + +
+ ))} +
+ + + ); +} +render(); ``` ::: :::vue -```html title="UsersPage.vue" - + +``` + +```html title="OrderSummary.vue" collapsed +``` + +```html title="CartPage.vue" collapsed + + + ``` :::
+ +Computing these totals in a component instead would mean fetching both kinds of data +there, repeating the math wherever totals show, or passing them down as props. With a +`Query`: + +- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each + other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one + `useQuery()` call. +- **Computed once per change.** `useQuery()` results are memoized globally against the entities + they read. However many components read `getCartTotals`, it runs once when a `CartItem` or + `Product` changes, and otherwise returns the same object. +- **Always current.** Any change to an item, like this demo's [optimistic](../api/resource.md#optimistic) + quantity updates, re-derives every total at once, with nothing to invalidate or sync. diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 14bfe8fc28b8..5b1a5b614d11 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -478,9 +478,12 @@ div.playground-preview h1:first-child { gap: var(--ds-space-4); margin: 0; } +/* at the foot of the row, so a figure in plain text (Free) keeps its + label in line with the animated ones' */ .playground-preview .stats > div { display: flex; flex-direction: column; + justify-content: flex-end; gap: 2px; } /* above its label, so a label that wraps grows down and the figures stay @@ -493,6 +496,63 @@ div.playground-preview h1:first-child { line-height: 1.1; font-variant-numeric: tabular-nums; } +/* A quantity stepper (a cart row's): − count + in one edge, the count + between its two buttons. In a row it sits at the end, before a trailing + `meta` (a line's price), which keeps one width so the rows' prices align */ +.playground-preview .stepper { + flex: none; + display: inline-flex; + align-items: center; + border: 1px solid var(--ds-border-strong); + border-radius: var(--ds-radius-sm); +} +.playground-preview .stepper > button { + width: 28px; + height: 28px; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--ds-radius-sm); + background: none; + color: var(--ds-text-muted); + font: 500 1rem / 1 var(--ifm-font-family-base); + cursor: pointer; + transition: + background-color var(--motion-snappy), + color var(--motion-snappy); +} +@media (hover: hover) { + .playground-preview .stepper > button:hover { + background: var(--ds-accent-soft); + color: var(--ds-accent); + } +} +.playground-preview .stepper > button:active { + background: color-mix( + in srgb, + var(--ds-accent) var(--ds-accent-press), + var(--ds-surface) + ); + color: var(--ds-accent); +} +.playground-preview .stepper > button:focus-visible { + outline: 2px solid var(--ds-accent); + outline-offset: -2px; +} +.playground-preview .stepper > output { + min-width: 1.5rem; + font-weight: 600; + text-align: center; + font-variant-numeric: tabular-nums; +} +.playground-preview .listItem > .stepper { + margin-left: auto; +} +.playground-preview .listItem > .stepper + .meta:last-child { + min-width: 4rem; + margin-left: 0; + text-align: right; +} /* A board's columns (RestEndpoint's move demo): wells a step off the sheet, each holding its tasks as rows of the sheet's color (radius 8, no edge), 6px apart */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index e71659ceabea..e583f0989b80 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -241,6 +241,7 @@ Styled elements: `h2` (page title), `button` (`primary` fills one; | `boardColumn` | a kanban column | | `upvote` (a button) | an upvote with its count; empty, the arrow alone | | `stats` (a `dl`) | figures side by side, each large over its label | +| `stepper` (− `output` +) | a quantity's buttons around its count; in a row, before its trailing `meta` | ## Auto-run diff --git a/website/src/fixtures/cart.ts b/website/src/fixtures/cart.ts new file mode 100644 index 000000000000..6381f1c281e3 --- /dev/null +++ b/website/src/fixtures/cart.ts @@ -0,0 +1,81 @@ +import { Entity, resource } from '@data-client/rest'; +import type { Interceptor } from '@data-client/test'; + +type CartItemData = { + id: string; + product: { id: string; name: string; price: number }; + quantity: number; +}; + +type CartInterceptorState = { + items: Record; +}; + +class Product extends Entity { + id = ''; + name = ''; + price = 0; +} +class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + static schema = { product: Product }; +} +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, +}); + +// $48: one more filter pack crosses the $50 free shipping line +const initialItems: Record = { + '1': { + id: '1', + product: { id: 'house-blend', name: 'House blend', price: 16 }, + quantity: 2, + }, + '2': { + id: '2', + product: { id: 'mug', name: 'Ceramic mug', price: 12 }, + quantity: 1, + }, + '3': { + id: '3', + product: { id: 'filters', name: 'Paper filters', price: 4 }, + quantity: 1, + }, +}; + +export const getInitialInterceptorData = (): CartInterceptorState => ({ + items: structuredClone(initialItems), +}); + +export const cartFixtures: Interceptor[] = [ + { + endpoint: CartResource.getList, + response() { + return Object.values(this.items); + }, + delay: 150, + }, + { + endpoint: CartResource.partialUpdate, + response( + { id }: Parameters[0], + body: Parameters[1], + ) { + this.items[id] = { ...this.items[id], ...body }; + return this.items[id]; + }, + delay: 300, + }, + { + endpoint: CartResource.delete, + response({ id }: Parameters[0]) { + delete this.items[id]; + return { id }; + }, + delay: 300, + }, +]; From 6b989175e959b282dd02479014175998a008e975 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:36:42 +0000 Subject: [PATCH 02/12] docs: Share the stepper's press styles with upvote; align stats on a subgrid The stepper's hover and press join upvote's rules and its focus ring keeps the shared one. Stats columns share one row for figures and one for labels, so a plain-text figure sits level without moving the figures of a column whose label wraps. The cart row's text takes the slack instead of resetting the trailing price's auto margin, and the fixture's types come from its data. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../Playground/DesignSystem/design-system.css | 52 +++++++------------ website/src/fixtures/cart.ts | 16 ++---- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 5b1a5b614d11..4a940ce24ba7 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -455,12 +455,14 @@ div.playground-preview h1:first-child { clip-path: polygon(50% 0, 100% 100%, 0 100%); } @media (hover: hover) { - .playground-preview button.upvote:hover { + .playground-preview button.upvote:hover, + .playground-preview .stepper > button:hover { background: var(--ds-accent-soft); color: var(--ds-accent); } } -.playground-preview button.upvote:active { +.playground-preview button.upvote:active, +.playground-preview .stepper > button:active { background: color-mix( in srgb, var(--ds-accent) var(--ds-accent-press), @@ -470,21 +472,21 @@ div.playground-preview h1:first-child { } /* Figures side by side (`dl.stats`, each a div of dt then dd): the number large over its muted label, so a figure that moves is what catches the - eye; one row of columns up to 9rem wide, whose labels wrap on a phone */ + eye; one row of columns up to 9rem wide, whose labels wrap on a phone. + The figures share one grid row and the labels another, so a figure in + plain text (Free) sits level with animated ones */ .playground-preview .stats { display: grid; + grid-template-rows: auto auto; grid-auto-flow: column; grid-auto-columns: minmax(0, 9rem); - gap: var(--ds-space-4); + gap: 2px var(--ds-space-4); margin: 0; } -/* at the foot of the row, so a figure in plain text (Free) keeps its - label in line with the animated ones' */ .playground-preview .stats > div { - display: flex; - flex-direction: column; - justify-content: flex-end; - gap: 2px; + display: grid; + grid-row: span 2; + grid-template-rows: subgrid; } /* above its label, so a label that wraps grows down and the figures stay in line */ @@ -494,11 +496,13 @@ div.playground-preview h1:first-child { font-size: var(--ds-text-2xl); font-weight: 600; line-height: 1.1; + align-self: center; font-variant-numeric: tabular-nums; } /* A quantity stepper (a cart row's): − count + in one edge, the count - between its two buttons. In a row it sits at the end, before a trailing - `meta` (a line's price), which keeps one width so the rows' prices align */ + between its two buttons; hover and press as an upvote's. In a row the + text before it takes the slack, and a trailing `meta` (a line's price) + keeps one width so the rows' prices align */ .playground-preview .stepper { flex: none; display: inline-flex; @@ -516,27 +520,12 @@ div.playground-preview h1:first-child { background: none; color: var(--ds-text-muted); font: 500 1rem / 1 var(--ifm-font-family-base); - cursor: pointer; transition: background-color var(--motion-snappy), color var(--motion-snappy); } -@media (hover: hover) { - .playground-preview .stepper > button:hover { - background: var(--ds-accent-soft); - color: var(--ds-accent); - } -} -.playground-preview .stepper > button:active { - background: color-mix( - in srgb, - var(--ds-accent) var(--ds-accent-press), - var(--ds-surface) - ); - color: var(--ds-accent); -} +/* inside the stepper's edge */ .playground-preview .stepper > button:focus-visible { - outline: 2px solid var(--ds-accent); outline-offset: -2px; } .playground-preview .stepper > output { @@ -545,12 +534,11 @@ div.playground-preview h1:first-child { text-align: center; font-variant-numeric: tabular-nums; } -.playground-preview .listItem > .stepper { - margin-left: auto; +.playground-preview .listItem:has(> .stepper) > :first-child { + flex: 1; } -.playground-preview .listItem > .stepper + .meta:last-child { +.playground-preview .listItem:has(> .stepper) > .meta:last-child { min-width: 4rem; - margin-left: 0; text-align: right; } /* A board's columns (RestEndpoint's move demo): wells a step off the diff --git a/website/src/fixtures/cart.ts b/website/src/fixtures/cart.ts index 6381f1c281e3..c0f249c35c58 100644 --- a/website/src/fixtures/cart.ts +++ b/website/src/fixtures/cart.ts @@ -1,16 +1,6 @@ import { Entity, resource } from '@data-client/rest'; import type { Interceptor } from '@data-client/test'; -type CartItemData = { - id: string; - product: { id: string; name: string; price: number }; - quantity: number; -}; - -type CartInterceptorState = { - items: Record; -}; - class Product extends Entity { id = ''; name = ''; @@ -29,7 +19,7 @@ export const CartResource = resource({ }); // $48: one more filter pack crosses the $50 free shipping line -const initialItems: Record = { +const initialItems = { '1': { id: '1', product: { id: 'house-blend', name: 'House blend', price: 16 }, @@ -47,6 +37,10 @@ const initialItems: Record = { }, }; +type CartInterceptorState = { + items: Record; +}; + export const getInitialInterceptorData = (): CartInterceptorState => ({ items: structuredClone(initialItems), }); From 7973eefc97772766bddf7b8b414821524d64925d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:43:30 +0000 Subject: [PATCH 03/12] docs: Keep an empty cart's free-shipping line consistent; fit phones Shipping now follows from the amount left until free shipping, so an emptied cart no longer reads "Add $50.00 more" beside "Free". On touch screens the stepper's buttons grow to 40px, and on phones under 360px the stats figures step down a size so three prices fit side by side. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/computed-properties.md | 15 +++++---------- .../references/computed-properties.vue.md | 15 +++++---------- docs/rest/guides/computed-properties.md | 15 +++++---------- .../Playground/DesignSystem/design-system.css | 13 +++++++++++++ 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index a932624e0e93..3bbabb112fbb 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -104,22 +104,17 @@ export const CartResource = resource({ export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```ts title="getCartTotals" {4-17} +```ts title="getCartTotals" {4-11} import { All, Query } from '@data-client/rest'; import { CartItem } from './resources/Cart'; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const freeShippingAt = 50; - const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; - return { - count, - subtotal, - shipping, - total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), - }; + const toFreeShipping = Math.max(0, 50 - subtotal); + const shipping = toFreeShipping ? 5 : 0; + const total = subtotal + shipping; + return { count, subtotal, shipping, total, toFreeShipping }; }); ``` diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md index 673c1de21fc0..563b66cb48f8 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.vue.md +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -104,22 +104,17 @@ export const CartResource = resource({ export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```ts title="getCartTotals" {4-17} +```ts title="getCartTotals" {4-11} import { All, Query } from '@data-client/rest'; import { CartItem } from './resources/Cart'; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const freeShippingAt = 50; - const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; - return { - count, - subtotal, - shipping, - total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), - }; + const toFreeShipping = Math.max(0, 50 - subtotal); + const shipping = toFreeShipping ? 5 : 0; + const total = subtotal + shipping; + return { count, subtotal, shipping, total, toFreeShipping }; }); ``` diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index 60297696cb26..f2e7c226949d 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -133,22 +133,17 @@ export const CartResource = resource({ export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```ts title="getCartTotals" {4-17} +```ts title="getCartTotals" {4-11} import { All, Query } from '@data-client/rest'; import { CartItem } from './resources/Cart'; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const freeShippingAt = 50; - const shipping = subtotal === 0 || subtotal >= freeShippingAt ? 0 : 5; - return { - count, - subtotal, - shipping, - total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), - }; + const toFreeShipping = Math.max(0, 50 - subtotal); + const shipping = toFreeShipping ? 5 : 0; + const total = subtotal + shipping; + return { count, subtotal, shipping, total, toFreeShipping }; }); ``` diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 4a940ce24ba7..4527bfb7e733 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -499,6 +499,12 @@ div.playground-preview h1:first-child { align-self: center; font-variant-numeric: tabular-nums; } +/* the smallest phones: three prices ($52.00) still fit side by side */ +@media (max-width: 360px) { + .playground-preview .stats dd { + font-size: var(--ds-text-xl); + } +} /* A quantity stepper (a cart row's): − count + in one edge, the count between its two buttons; hover and press as an upvote's. In a row the text before it takes the slack, and a trailing `meta` (a line's price) @@ -524,6 +530,13 @@ div.playground-preview h1:first-child { background-color var(--motion-snappy), color var(--motion-snappy); } +/* a fingertip's target on touch screens */ +@media (pointer: coarse) { + .playground-preview .stepper > button { + width: 40px; + height: 40px; + } +} /* inside the stepper's edge */ .playground-preview .stepper > button:focus-visible { outline-offset: -2px; From 3b53d23adf38fc7acd0d662ad37bc846c6ea8c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:51:37 +0000 Subject: [PATCH 04/12] docs: Read the cart's totals from four places across a Shop and Cart page The demo becomes a small routed store, so the one Query visibly serves many readers: the header's cart badge on every page, a free-shipping bar on both pages, and the cart's order summary and checkout button. Adding a product in the shop moves all of them; the cart page already has the totals. Products add with getList.push, so the cart list stays whole. The design system gains a progress element and the stepper's single Add state; an empty cart ships free again. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/computed-properties.md | 282 ++++++--- .../references/computed-properties.vue.md | 295 +++++++--- docs/rest/guides/computed-properties.md | 540 +++++++++++++----- .../Playground/DesignSystem/design-system.css | 52 +- website/src/components/Playground/README.md | 4 +- website/src/fixtures/cart.ts | 72 ++- 6 files changed, 885 insertions(+), 360 deletions(-) diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index 3bbabb112fbb..024c7acadbbe 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -70,9 +70,12 @@ class User extends Entity { (an aggregate). Define it once, beside your resources, and read it from any component with [useQuery](https://dataclient.io/docs/api/useQuery). -Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge -shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read -`getCartTotals`; change a quantity and both follow. +In this store, `getCartTotals` joins each `CartItem`'s quantity with its `Product`'s price. Four +components on two pages read it: the header's cart badge, the free-shipping bar on both pages, +and the cart's order summary and checkout button. Add something in the shop, and every one of +them moves at once. + +Files with a `route` are pages mounted at that path, with its params as props; `Link` and `useNavigate()` stand for your router (with react-router, read params with `useParams()`). ```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; @@ -82,7 +85,12 @@ export class Product extends Entity { name = ''; price = 0; } +export const ProductResource = resource({ + path: '/products/:id', + schema: Product, +}); +/** A line in the cart, keyed by its product's id */ export class CartItem extends Entity { id = ''; product = Product.fromJS(); @@ -94,7 +102,6 @@ export class CartItem extends Entity { static schema = { product: Product }; } - export const CartResource = resource({ path: '/cart/items/:id', schema: CartItem, @@ -104,17 +111,23 @@ export const CartResource = resource({ export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```ts title="getCartTotals" {4-11} +```ts title="getCartTotals" {6-18} import { All, Query } from '@data-client/rest'; import { CartItem } from './resources/Cart'; +export const freeShippingAt = 50; + export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const toFreeShipping = Math.max(0, 50 - subtotal); - const shipping = toFreeShipping ? 5 : 0; - const total = subtotal + shipping; - return { count, subtotal, shipping, total, toFreeShipping }; + const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; }); ``` @@ -128,10 +141,33 @@ export default function CartBadge() { const totals = useQuery(getCartTotals); if (!totals) return null; return ( - + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} - + + ); +} +``` + +```tsx title="FreeShipping" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { freeShippingAt, getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function FreeShipping() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} + +

); } ``` @@ -146,106 +182,188 @@ export default function OrderSummary() { const totals = useQuery(getCartTotals); if (!totals) return null; return ( - <> -

- {totals.toFreeShipping > 0 ? - <> - Add more - for free shipping - - : 'Your order ships free'} -

-
-
-
Subtotal
-
-
-
-
Shipping
-
- {totals.shipping ? - - : 'Free'} -
-
-
-
Total
-
-
-
- +
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
); } ``` -```tsx title="CartPage" -import { useController, useSuspense } from '@data-client/react'; +```tsx title="CheckoutButton" +import { useQuery } from '@data-client/react'; import NumberFlow from '@number-flow/react'; -import CartBadge from './CartBadge'; -import OrderSummary from './OrderSummary'; -import { CartItem, CartResource, usd } from './resources/Cart'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; -function CartPage() { - const items = useSuspense(CartResource.getList); +export default function CheckoutButton() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + + ); +} +``` + +```tsx title="QuantityStepper" +import { useController, useQuery } from '@data-client/react'; +import { CartItem, CartResource, Product } from './resources/Cart'; + +export default function QuantityStepper({ product }: { product: Product }) { const ctrl = useController(); - const setQuantity = (item: CartItem, quantity: number) => + const item = useQuery(CartItem, { id: product.id }); + const add = () => + ctrl.fetch(CartResource.getList.push, { + id: product.id, + product, + quantity: 1, + }); + const setQuantity = (quantity: number) => quantity ? - ctrl.fetch(CartResource.partialUpdate, { id: item.id }, { quantity }) - : ctrl.fetch(CartResource.delete, { id: item.id }); + ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: product.id }); + + if (!item) + return ( + + + + ); + return ( + + + {item.quantity} + + + ); +} +``` + +```tsx title="ShopPage" route="/" +import { useSuspense } from '@data-client/react'; +import FreeShipping from './FreeShipping'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, ProductResource, usd } from './resources/Cart'; +export default function ShopPage() { + const products = useSuspense(ProductResource.getList); + useSuspense(CartResource.getList); return ( <> -
-

Daily Grind

- -
-

Cart

+
- {items.map(item => ( -
+ {products.map(product => ( +
- {item.product.name} + {product.name}
- + {product.price.toLocaleString('en-US', usd)}
- - - {item.quantity} - - + +
+ ))} +
+ + ); +} +``` + +```tsx title="CartPage" route="/cart" +import { useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CheckoutButton from './CheckoutButton'; +import FreeShipping from './FreeShipping'; +import OrderSummary from './OrderSummary'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, usd } from './resources/Cart'; + +export default function CartPage() { + const items = useSuspense(CartResource.getList); + if (!items.length) return

Your cart is empty

; + return ( + <> +
+ {items.map(item => ( +
+
{item.product.name}
+
))}
+ + + + ); +} +``` + +```tsx title="Store" layout +import { useFetch } from '@data-client/react'; +import CartBadge from './CartBadge'; +import { CartResource } from './resources/Cart'; + +export default function Store({ children }: { children: React.ReactNode }) { + // every page shows the cart's totals, so the cart loads with the app + useFetch(CartResource.getList); + return ( + <> +
+

Daily Grind

+ +
+ + {children} ); } -render(); ``` -Computing these totals in a component instead would mean fetching both kinds of data -there, repeating the math wherever totals show, or passing them down as props. With a -`Query`: +Computing these totals in each component instead would repeat the math in all four, each +fetching or being passed both kinds of data, and free to drift (one rounds, one forgets the +shipping rule). With a `Query`: -- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each - other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one - `useQuery()` call. +- **One definition, read anywhere.** The badge, the bar, the summary and the checkout button + never pass totals to each other, and they can't disagree. A fifth place that needs the total + is one `useQuery()` call. - **Computed once per change.** `useQuery()` results are memoized globally against the entities they read. However many components read `getCartTotals`, it runs once when a `CartItem` or `Product` changes, and otherwise returns the same object. -- **Always current.** Any change to an item, like this demo's [optimistic](https://dataclient.io/rest/api/resource#optimistic) - quantity updates, re-derives every total at once, with nothing to invalidate or sync. +- **Always current.** Any change to the cart, from any page, like this demo's + [optimistic](https://dataclient.io/rest/api/resource#optimistic) updates, re-derives every total at once, with + nothing to invalidate or sync. diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md index 563b66cb48f8..4a6f71da7c5a 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.vue.md +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -70,9 +70,12 @@ class User extends Entity { (an aggregate). Define it once, beside your resources, and read it from any component with [useQuery](https://dataclient.io/vue/api/useQuery). -Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge -shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read -`getCartTotals`; change a quantity and both follow. +In this store, `getCartTotals` joins each `CartItem`'s quantity with its `Product`'s price. Four +components on two pages read it: the header's cart badge, the free-shipping bar on both pages, +and the cart's order summary and checkout button. Add something in the shop, and every one of +them moves at once. + +Files with a `route` are pages mounted at that path, with its params as props (vue-router `props: true`). ```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; @@ -82,7 +85,12 @@ export class Product extends Entity { name = ''; price = 0; } +export const ProductResource = resource({ + path: '/products/:id', + schema: Product, +}); +/** A line in the cart, keyed by its product's id */ export class CartItem extends Entity { id = ''; product = Product.fromJS(); @@ -94,7 +102,6 @@ export class CartItem extends Entity { static schema = { product: Product }; } - export const CartResource = resource({ path: '/cart/items/:id', schema: CartItem, @@ -104,17 +111,23 @@ export const CartResource = resource({ export const usd = { style: 'currency', currency: 'USD' } as const; ``` -```ts title="getCartTotals" {4-11} +```ts title="getCartTotals" {6-18} import { All, Query } from '@data-client/rest'; import { CartItem } from './resources/Cart'; +export const freeShippingAt = 50; + export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const toFreeShipping = Math.max(0, 50 - subtotal); - const shipping = toFreeShipping ? 5 : 0; - const total = subtotal + shipping; - return { count, subtotal, shipping, total, toFreeShipping }; + const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; }); ``` @@ -129,10 +142,32 @@ export const getCartTotals = new Query(new All(CartItem), items => { +``` + +```html title="FreeShipping.vue" + + + ``` @@ -147,53 +182,160 @@ export const getCartTotals = new Query(new All(CartItem), items => { ``` -```html title="CartPage.vue" +```html title="CartPage.vue" route="/cart" + + +``` + +```html title="Store.vue" layout + ``` -Computing these totals in a component instead would mean fetching both kinds of data -there, repeating the math wherever totals show, or passing them down as props. With a -`Query`: +Computing these totals in each component instead would repeat the math in all four, each +fetching or being passed both kinds of data, and free to drift (one rounds, one forgets the +shipping rule). With a `Query`: -- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each - other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one - `useQuery()` call. +- **One definition, read anywhere.** The badge, the bar, the summary and the checkout button + never pass totals to each other, and they can't disagree. A fifth place that needs the total + is one `useQuery()` call. - **Computed once per change.** `useQuery()` results are memoized globally against the entities they read. However many components read `getCartTotals`, it runs once when a `CartItem` or `Product` changes, and otherwise returns the same object. -- **Always current.** Any change to an item, like this demo's [optimistic](https://dataclient.io/rest/api/resource#optimistic) - quantity updates, re-derives every total at once, with nothing to invalidate or sync. +- **Always current.** Any change to the cart, from any page, like this demo's + [optimistic](https://dataclient.io/rest/api/resource#optimistic) updates, re-derives every total at once, with + nothing to invalidate or sync. diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index f2e7c226949d..df5ffa575754 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -9,12 +9,15 @@ import { } from '@site/src/fixtures/cart'; export const cartDemo = [ - // $48 to $52: free shipping, and the total drops + // $48 to $52: the bar fills, and shipping turns free { delay: 1200, click: 'button[aria-label="Add one Paper filters"]' }, - { wait: 2200 }, - { click: 'button[aria-label="Remove one House blend"]' }, - { wait: 2200 }, - { click: 'button[aria-label="Add one House blend"]' }, + { wait: 1800 }, + { click: 'button[aria-label="Add Pour-over dripper"]' }, + { wait: 1800 }, + // the cart's totals are already there + { click: 'nav a[href="/cart"]' }, + { wait: 2500 }, + { click: 'button[aria-label="Remove one Pour-over dripper"]' }, ]; ## Singular computations @@ -91,9 +94,10 @@ class User extends Entity { (an aggregate). Define it once, beside your resources, and read it from any component with [useQuery](/docs/api/useQuery). -Here a cart's totals join each `CartItem`'s quantity with its `Product`'s price, and charge -shipping under $50. The header's `CartBadge` and the `OrderSummary` under the items both read -`getCartTotals`; change a quantity and both follow. +In this store, `getCartTotals` joins each `CartItem`'s quantity with its `Product`'s price. Four +components on two pages read it: the header's cart badge, the free-shipping bar on both pages, +and the cart's order summary and checkout button. Add something in the shop, and every one of +them moves at once. { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const toFreeShipping = Math.max(0, 50 - subtotal); - const shipping = toFreeShipping ? 5 : 0; - const total = subtotal + shipping; - return { count, subtotal, shipping, total, toFreeShipping }; + const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping: Math.max(0, freeShippingAt - subtotal), + }; }); ``` @@ -159,10 +173,33 @@ export default function CartBadge() { const totals = useQuery(getCartTotals); if (!totals) return null; return ( - + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} - + + ); +} +``` + +```tsx title="FreeShipping" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { freeShippingAt, getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function FreeShipping() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} + +

); } ``` @@ -177,95 +214,176 @@ export default function OrderSummary() { const totals = useQuery(getCartTotals); if (!totals) return null; return ( - <> -

- {totals.toFreeShipping > 0 ? - <> - Add more - for free shipping - - : 'Your order ships free'} -

-
-
-
Subtotal
-
-
-
-
Shipping
-
- {totals.shipping ? - - : 'Free'} -
-
-
-
Total
-
-
-
- +
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
); } ``` -```tsx title="CartPage" collapsed -import { useController, useSuspense } from '@data-client/react'; +```tsx title="CheckoutButton" collapsed +import { useQuery } from '@data-client/react'; import NumberFlow from '@number-flow/react'; -import CartBadge from './CartBadge'; -import OrderSummary from './OrderSummary'; -import { CartItem, CartResource, usd } from './resources/Cart'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; -function CartPage() { - const items = useSuspense(CartResource.getList); +export default function CheckoutButton() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + + ); +} +``` + +```tsx title="QuantityStepper" collapsed +import { useController, useQuery } from '@data-client/react'; +import { CartItem, CartResource, Product } from './resources/Cart'; + +export default function QuantityStepper({ product }: { product: Product }) { const ctrl = useController(); - const setQuantity = (item: CartItem, quantity: number) => + const item = useQuery(CartItem, { id: product.id }); + const add = () => + ctrl.fetch(CartResource.getList.push, { + id: product.id, + product, + quantity: 1, + }); + const setQuantity = (quantity: number) => quantity ? - ctrl.fetch(CartResource.partialUpdate, { id: item.id }, { quantity }) - : ctrl.fetch(CartResource.delete, { id: item.id }); + ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: product.id }); + + if (!item) + return ( + + + + ); + return ( + + + {item.quantity} + + + ); +} +``` + +```tsx title="ShopPage" route="/" +import { useSuspense } from '@data-client/react'; +import FreeShipping from './FreeShipping'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, ProductResource, usd } from './resources/Cart'; +export default function ShopPage() { + const products = useSuspense(ProductResource.getList); + useSuspense(CartResource.getList); return ( <> -
-

Daily Grind

- -
-

Cart

+
- {items.map(item => ( -
+ {products.map(product => ( +
- {item.product.name} + {product.name}
- + {product.price.toLocaleString('en-US', usd)}
- - - {item.quantity} - - + +
+ ))} +
+ + ); +} +``` + +```tsx title="CartPage" route="/cart" collapsed +import { useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CheckoutButton from './CheckoutButton'; +import FreeShipping from './FreeShipping'; +import OrderSummary from './OrderSummary'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, usd } from './resources/Cart'; + +export default function CartPage() { + const items = useSuspense(CartResource.getList); + if (!items.length) return

Your cart is empty

; + return ( + <> +
+ {items.map(item => ( +
+
{item.product.name}
+
))}
+ + + + ); +} +``` + +```tsx title="Store" layout collapsed +import { useFetch } from '@data-client/react'; +import CartBadge from './CartBadge'; +import { CartResource } from './resources/Cart'; + +export default function Store({ children }: { children: React.ReactNode }) { + // every page shows the cart's totals, so the cart loads with the app + useFetch(CartResource.getList); + return ( + <> +
+

Daily Grind

+ +
+ + {children} ); } -render(); ``` ::: @@ -283,10 +401,32 @@ render(); +``` + +```html title="FreeShipping.vue" collapsed + + + ``` @@ -301,53 +441,160 @@ render(); ``` -```html title="CartPage.vue" collapsed +```html title="CartPage.vue" route="/cart" collapsed + + +``` + +```html title="Store.vue" layout collapsed + ``` @@ -392,15 +619,16 @@ render(); -Computing these totals in a component instead would mean fetching both kinds of data -there, repeating the math wherever totals show, or passing them down as props. With a -`Query`: +Computing these totals in each component instead would repeat the math in all four, each +fetching or being passed both kinds of data, and free to drift (one rounds, one forgets the +shipping rule). With a `Query`: -- **One definition, read anywhere.** `CartBadge` and `OrderSummary` never pass totals to each - other, so they can't disagree. Showing the total somewhere new, like a checkout button, is one - `useQuery()` call. +- **One definition, read anywhere.** The badge, the bar, the summary and the checkout button + never pass totals to each other, and they can't disagree. A fifth place that needs the total + is one `useQuery()` call. - **Computed once per change.** `useQuery()` results are memoized globally against the entities they read. However many components read `getCartTotals`, it runs once when a `CartItem` or `Product` changes, and otherwise returns the same object. -- **Always current.** Any change to an item, like this demo's [optimistic](../api/resource.md#optimistic) - quantity updates, re-derives every total at once, with nothing to invalidate or sync. +- **Always current.** Any change to the cart, from any page, like this demo's + [optimistic](../api/resource.md#optimistic) updates, re-derives every total at once, with + nothing to invalidate or sync. diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 4527bfb7e733..ae2081d8f673 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -505,11 +505,17 @@ div.playground-preview h1:first-child { font-size: var(--ds-text-xl); } } +/* the action the figures add up to (Check out) stands off them */ +.playground-preview .stats + button { + margin-top: var(--ds-space-4); +} /* A quantity stepper (a cart row's): − count + in one edge, the count - between its two buttons; hover and press as an upvote's. In a row the - text before it takes the slack, and a trailing `meta` (a line's price) - keeps one width so the rows' prices align */ + between its two buttons; hover and press as an upvote's. With one button + (Add, at none) it keeps the same width, so a row doesn't shift. In a row + the text before it takes the slack, and a trailing `meta` (a line's + price) keeps one width so the rows' prices align */ .playground-preview .stepper { + --stepper-button: 28px; flex: none; display: inline-flex; align-items: center; @@ -517,8 +523,8 @@ div.playground-preview h1:first-child { border-radius: var(--ds-radius-sm); } .playground-preview .stepper > button { - width: 28px; - height: 28px; + width: var(--stepper-button); + height: var(--stepper-button); margin: 0; padding: 0; border: 0; @@ -532,11 +538,16 @@ div.playground-preview h1:first-child { } /* a fingertip's target on touch screens */ @media (pointer: coarse) { - .playground-preview .stepper > button { - width: 40px; - height: 40px; + .playground-preview .stepper { + --stepper-button: 40px; } } +.playground-preview .stepper > button:only-child { + width: calc(2 * var(--stepper-button) + 1.5rem); + color: var(--ds-accent); + font-size: var(--ds-text-sm); + font-weight: 600; +} /* inside the stepper's edge */ .playground-preview .stepper > button:focus-visible { outline-offset: -2px; @@ -554,6 +565,31 @@ div.playground-preview h1:first-child { min-width: 4rem; text-align: right; } +/* A meter (progress toward free shipping): a thin rounded track a step + off the sheet, filled in the accent */ +.playground-preview progress { + display: block; + width: 100%; + height: 6px; + margin-top: var(--ds-space-2); + appearance: none; + border: 0; + border-radius: 999px; + background: var(--ds-surface-2); + overflow: hidden; +} +.playground-preview progress::-webkit-progress-bar { + background: var(--ds-surface-2); +} +.playground-preview progress::-webkit-progress-value { + background: var(--ds-accent); + border-radius: 999px; + transition: inline-size var(--motion-snappy); +} +.playground-preview progress::-moz-progress-bar { + background: var(--ds-accent); + border-radius: 999px; +} /* A board's columns (RestEndpoint's move demo): wells a step off the sheet, each holding its tasks as rows of the sheet's color (radius 8, no edge), 6px apart */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index e583f0989b80..65eb071f9a6f 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -225,7 +225,7 @@ controls keep an edge). Before restyling a class, grep `docs/` and style to a new component. Styled elements: `h2` (page title), `button` (`primary` fills one; -`aria-busy` shows pending), text inputs, `nav`, `fieldset`. +`aria-busy` shows pending), text inputs, `nav`, `fieldset`, `progress`. | Class | Use for | | ------------------------------------------ | ------------------------------------------------------------------------------ | @@ -241,7 +241,7 @@ Styled elements: `h2` (page title), `button` (`primary` fills one; | `boardColumn` | a kanban column | | `upvote` (a button) | an upvote with its count; empty, the arrow alone | | `stats` (a `dl`) | figures side by side, each large over its label | -| `stepper` (− `output` +) | a quantity's buttons around its count; in a row, before its trailing `meta` | +| `stepper` (− `output` +) | a quantity's buttons around its count (one button alone: Add); in a row | ## Auto-run diff --git a/website/src/fixtures/cart.ts b/website/src/fixtures/cart.ts index c0f249c35c58..635519c65764 100644 --- a/website/src/fixtures/cart.ts +++ b/website/src/fixtures/cart.ts @@ -1,11 +1,16 @@ import { Entity, resource } from '@data-client/rest'; import type { Interceptor } from '@data-client/test'; +type ProductData = { id: string; name: string; price: number }; +type CartItemData = { id: string; product: ProductData; quantity: number }; +type CartInterceptorState = { items: Record }; + class Product extends Entity { id = ''; name = ''; price = 0; } +const ProductResource = resource({ path: '/products/:id', schema: Product }); class CartItem extends Entity { id = ''; product = Product.fromJS(); @@ -13,39 +18,45 @@ class CartItem extends Entity { static schema = { product: Product }; } -export const CartResource = resource({ - path: '/cart/items/:id', - schema: CartItem, -}); +const CartResource = resource({ path: '/cart/items/:id', schema: CartItem }); -// $48: one more filter pack crosses the $50 free shipping line -const initialItems = { - '1': { - id: '1', - product: { id: 'house-blend', name: 'House blend', price: 16 }, - quantity: 2, - }, - '2': { - id: '2', - product: { id: 'mug', name: 'Ceramic mug', price: 12 }, - quantity: 1, - }, - '3': { - id: '3', - product: { id: 'filters', name: 'Paper filters', price: 4 }, - quantity: 1, - }, -}; +const products: ProductData[] = [ + { id: 'house-blend', name: 'House blend', price: 16 }, + { id: 'mug', name: 'Ceramic mug', price: 12 }, + { id: 'filters', name: 'Paper filters', price: 4 }, + { id: 'dripper', name: 'Pour-over dripper', price: 22 }, +]; -type CartInterceptorState = { - items: Record; +// $48: one more filter pack crosses the $50 free shipping line +const initialQuantities: Record = { + 'house-blend': 2, + mug: 1, + filters: 1, }; export const getInitialInterceptorData = (): CartInterceptorState => ({ - items: structuredClone(initialItems), + items: Object.fromEntries( + products + .filter(product => initialQuantities[product.id]) + .map(product => [ + product.id, + { + id: product.id, + product, + quantity: initialQuantities[product.id], + }, + ]), + ), }); export const cartFixtures: Interceptor[] = [ + { + endpoint: ProductResource.getList, + response() { + return products; + }, + delay: 150, + }, { endpoint: CartResource.getList, response() { @@ -53,13 +64,22 @@ export const cartFixtures: Interceptor[] = [ }, delay: 150, }, + { + endpoint: CartResource.getList.push, + response(body: Parameters[0]) { + const item = body as CartItemData; + this.items[item.id] = item; + return item; + }, + delay: 300, + }, { endpoint: CartResource.partialUpdate, response( { id }: Parameters[0], body: Parameters[1], ) { - this.items[id] = { ...this.items[id], ...body }; + this.items[id] = { ...this.items[id], ...body } as CartItemData; return this.items[id]; }, delay: 300, From 94a0fabb34c14ecf08eebcca11205944fae658aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:57:39 +0000 Subject: [PATCH 05/12] docs: Simplify the cart's stepper, totals and fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stepper renders one wrapper for both states and holds its width on itself, so Add and the − count + controls can't drift apart. Shipping derives from the amount left to free shipping again, with an empty cart free. The fixture's cart lines are a literal, typed from itself. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/computed-properties.md | 46 +++++++++--------- .../references/computed-properties.vue.md | 5 +- docs/rest/guides/computed-properties.md | 46 +++++++++--------- .../Playground/DesignSystem/design-system.css | 3 +- website/src/fixtures/cart.ts | 47 ++++++++----------- 5 files changed, 70 insertions(+), 77 deletions(-) diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index 024c7acadbbe..4c685e90ea97 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -120,13 +120,14 @@ export const freeShippingAt = 50; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; return { count, subtotal, shipping, total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), + toFreeShipping, }; }); ``` @@ -239,29 +240,28 @@ export default function QuantityStepper({ product }: { product: Product }) { ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) : ctrl.fetch(CartResource.delete, { id: product.id }); - if (!item) - return ( - - - - ); return ( - - {item.quantity} - + {item ? + <> + + {item.quantity} + + + : + } ); } diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md index 4a6f71da7c5a..b0c5b1e334fa 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.vue.md +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -120,13 +120,14 @@ export const freeShippingAt = 50; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; return { count, subtotal, shipping, total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), + toFreeShipping, }; }); ``` diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index df5ffa575754..2bfed2d181f9 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -150,13 +150,14 @@ export const freeShippingAt = 50; export const getCartTotals = new Query(new All(CartItem), items => { const count = items.reduce((sum, item) => sum + item.quantity, 0); const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const shipping = subtotal > 0 && subtotal < freeShippingAt ? 5 : 0; + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; return { count, subtotal, shipping, total: subtotal + shipping, - toFreeShipping: Math.max(0, freeShippingAt - subtotal), + toFreeShipping, }; }); ``` @@ -271,29 +272,28 @@ export default function QuantityStepper({ product }: { product: Product }) { ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) : ctrl.fetch(CartResource.delete, { id: product.id }); - if (!item) - return ( - - - - ); return ( - - {item.quantity} - + {item ? + <> + + {item.quantity} + + + : + } ); } diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index ae2081d8f673..a62867032967 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -517,6 +517,7 @@ div.playground-preview h1:first-child { .playground-preview .stepper { --stepper-button: 28px; flex: none; + min-width: calc(2 * var(--stepper-button) + 1.5rem); display: inline-flex; align-items: center; border: 1px solid var(--ds-border-strong); @@ -543,7 +544,7 @@ div.playground-preview h1:first-child { } } .playground-preview .stepper > button:only-child { - width: calc(2 * var(--stepper-button) + 1.5rem); + flex: 1; color: var(--ds-accent); font-size: var(--ds-text-sm); font-weight: 600; diff --git a/website/src/fixtures/cart.ts b/website/src/fixtures/cart.ts index 635519c65764..3bde5dfc9223 100644 --- a/website/src/fixtures/cart.ts +++ b/website/src/fixtures/cart.ts @@ -1,10 +1,6 @@ import { Entity, resource } from '@data-client/rest'; import type { Interceptor } from '@data-client/test'; -type ProductData = { id: string; name: string; price: number }; -type CartItemData = { id: string; product: ProductData; quantity: number }; -type CartInterceptorState = { items: Record }; - class Product extends Entity { id = ''; name = ''; @@ -20,40 +16,35 @@ class CartItem extends Entity { } const CartResource = resource({ path: '/cart/items/:id', schema: CartItem }); -const products: ProductData[] = [ - { id: 'house-blend', name: 'House blend', price: 16 }, - { id: 'mug', name: 'Ceramic mug', price: 12 }, - { id: 'filters', name: 'Paper filters', price: 4 }, - { id: 'dripper', name: 'Pour-over dripper', price: 22 }, -]; +const products = { + 'house-blend': { id: 'house-blend', name: 'House blend', price: 16 }, + mug: { id: 'mug', name: 'Ceramic mug', price: 12 }, + filters: { id: 'filters', name: 'Paper filters', price: 4 }, + dripper: { id: 'dripper', name: 'Pour-over dripper', price: 22 }, +}; // $48: one more filter pack crosses the $50 free shipping line -const initialQuantities: Record = { - 'house-blend': 2, - mug: 1, - filters: 1, +const initialItems = { + 'house-blend': { + id: 'house-blend', + product: products['house-blend'], + quantity: 2, + }, + mug: { id: 'mug', product: products.mug, quantity: 1 }, + filters: { id: 'filters', product: products.filters, quantity: 1 }, }; +type CartItemData = (typeof initialItems)[keyof typeof initialItems]; +type CartInterceptorState = { items: Record }; export const getInitialInterceptorData = (): CartInterceptorState => ({ - items: Object.fromEntries( - products - .filter(product => initialQuantities[product.id]) - .map(product => [ - product.id, - { - id: product.id, - product, - quantity: initialQuantities[product.id], - }, - ]), - ), + items: structuredClone(initialItems), }); export const cartFixtures: Interceptor[] = [ { endpoint: ProductResource.getList, response() { - return products; + return Object.values(products); }, delay: 150, }, @@ -79,7 +70,7 @@ export const cartFixtures: Interceptor[] = [ { id }: Parameters[0], body: Parameters[1], ) { - this.items[id] = { ...this.items[id], ...body } as CartItemData; + this.items[id] = { ...this.items[id], ...body }; return this.items[id]; }, delay: 300, From 25a458717659111a74669bd436d115bfe5cf9f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:03:18 +0000 Subject: [PATCH 06/12] docs: Keep a cart line at zero so adding it again doesn't duplicate the row Deleting a line left its id in the cart's Collection, and pushing the same product again appended a second copy, so the cart page showed the line twice while the totals counted it once. Lines now drop to quantity 0 and Add reuses them; the cart page hides empty lines. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/computed-properties.md | 25 ++++---- .../references/computed-properties.vue.md | 33 ++++++----- docs/rest/guides/computed-properties.md | 58 ++++++++++--------- website/src/fixtures/cart.ts | 8 --- 4 files changed, 64 insertions(+), 60 deletions(-) diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index 4c685e90ea97..6ed258716d34 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -229,20 +229,21 @@ import { CartItem, CartResource, Product } from './resources/Cart'; export default function QuantityStepper({ product }: { product: Product }) { const ctrl = useController(); const item = useQuery(CartItem, { id: product.id }); - const add = () => - ctrl.fetch(CartResource.getList.push, { - id: product.id, - product, - quantity: 1, - }); + // a line stays in the cart at 0, so adding it again reuses that line const setQuantity = (quantity: number) => - quantity ? - ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) - : ctrl.fetch(CartResource.delete, { id: product.id }); + ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }); + const add = () => + item ? + setQuantity(1) + : ctrl.fetch(CartResource.getList.push, { + id: product.id, + product, + quantity: 1, + }); return ( - {item ? + {item?.quantity ? <>