@salsita-ai/sdk 0.50.0-develop.2f8d91de91 → 0.50.0-develop.30ea3b5e1a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application-spec/generate-application/code/project-scaffolding.js +2 -2
- package/dist/application-spec/generate-application/code/standalone-app-files.d.ts +2 -2
- package/dist/application-spec/generate-application/code/standalone-app-files.js +1 -1
- package/dist/application-spec/generate-application/code/templates/index.d.ts +0 -1
- package/dist/application-spec/generate-application/code/templates/index.js +1 -1
- package/dist/application-spec/generate-application/code/templates/package-json.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +1 -1
- package/dist/application-spec/generate-application/code/templates/webhook-index.d.ts +6 -1
- package/dist/application-spec/generate-application/code/templates/webhook-index.js +10 -1
- package/dist/application-spec/generate-application/index.d.ts +2 -0
- package/dist/application-spec/generate-application/index.js +1 -1
- package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.d.ts +6 -2
- package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.js +1 -1
- package/dist/application-spec/generate-application/runtime/application-runtime.d.ts +8 -1
- package/dist/application-spec/generate-application/runtime/application-runtime.js +1 -1
- package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.d.ts +5 -1
- package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.js +1 -1
- package/dist/application-spec/generate-application/runtime/factory-data/factory-data-runtime.d.ts +43 -0
- package/dist/application-spec/generate-application/runtime/factory-data/factory-data-runtime.js +1 -0
- package/dist/application-spec/generate-application/runtime/factory-data/index.d.ts +3 -0
- package/dist/application-spec/generate-application/runtime/factory-data/index.js +1 -0
- package/dist/application-spec/generate-application/runtime/factory-data/recipe-defaults.d.ts +13 -0
- package/dist/application-spec/generate-application/runtime/factory-data/recipe-defaults.js +1 -0
- package/dist/application-spec/generate-application/runtime/factory-data/static-violations.d.ts +16 -0
- package/dist/application-spec/generate-application/runtime/factory-data/static-violations.js +1 -0
- package/dist/application-spec/generate-application/runtime/migrations/index.d.ts +1 -0
- package/dist/application-spec/generate-application/runtime/migrations/index.js +1 -0
- package/dist/application-spec/generate-application/runtime/migrations/migration-runtime.d.ts +53 -0
- package/dist/application-spec/generate-application/runtime/migrations/migration-runtime.js +1 -0
- package/dist/application-spec/parse-application-files/categorize/categorize-files.d.ts +6 -0
- package/dist/application-spec/parse-application-files/categorize/categorize-files.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +29 -0
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +7 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +6 -0
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
- package/dist/configurator/common/configurator-interface.d.ts +10 -1
- package/dist/configurator/common/configurator-interface.js +1 -1
- package/dist/configurator/common/index.d.ts +1 -0
- package/dist/configurator/common/index.js +1 -1
- package/dist/configurator/common/use-finalize-action.d.ts +5 -1
- package/dist/configurator/common/use-finalize-action.js +1 -1
- package/dist/configurator/common/use-reset-camera.d.ts +6 -0
- package/dist/configurator/common/use-reset-camera.js +1 -0
- package/dist/configurator/ui/assistant/AssistantChips.d.ts +8 -2
- package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
- package/dist/configurator/ui/assistant/AssistantThread.js +1 -1
- package/dist/docs/index.json +1 -1
- package/dist/docs/layouts/custom-layout.mdx +886 -161
- package/dist/docs/phoenix/configurator-interface.mdx +18 -10
- package/dist/docs/phoenix/layout-slots.mdx +4 -2
- package/dist/docs/phoenix/phoenix-cli.mdx +20 -6
- package/dist/docs/phoenix/project-structure.mdx +8 -3
- package/dist/docs/phoenix/sdk-builder-workflow.mdx +1 -1
- package/dist/docs/phoenix/webhooks.mdx +17 -0
- package/dist/domain/domain-context/index.d.ts +1 -1
- package/dist/domain/domain-context/undo-system.d.ts +28 -1
- package/dist/domain/domain-context/undo-system.js +1 -1
- package/dist/i18n/locales/cs/messages.js +1 -1
- package/dist/i18n/locales/da/messages.js +1 -1
- package/dist/i18n/locales/de/messages.js +1 -1
- package/dist/i18n/locales/de-AT/messages.js +1 -1
- package/dist/i18n/locales/en/messages.js +1 -1
- package/dist/i18n/locales/en-AU/messages.js +1 -1
- package/dist/i18n/locales/en-GB/messages.js +1 -1
- package/dist/i18n/locales/es/messages.js +1 -1
- package/dist/i18n/locales/fr/messages.js +1 -1
- package/dist/i18n/locales/hu/messages.js +1 -1
- package/dist/i18n/locales/it/messages.js +1 -1
- package/dist/i18n/locales/nl/messages.js +1 -1
- package/dist/i18n/locales/pl/messages.js +1 -1
- package/dist/i18n/locales/pt/messages.js +1 -1
- package/dist/i18n/locales/ro/messages.js +1 -1
- package/dist/i18n/locales/sk/messages.js +1 -1
- package/dist/i18n/locales/tr/messages.js +1 -1
- package/dist/phoenix/application-spec/application-spec-builder.js +1 -1
- package/dist/phoenix/commands/check.js +1 -1
- package/dist/phoenix/commands/dev.js +5 -5
- package/dist/phoenix/commands/fix.js +1 -1
- package/dist/phoenix/services/application-spec-project-watcher.d.ts +2 -0
- package/dist/phoenix/services/application-spec-project-watcher.js +3 -3
- package/dist/phoenix/services/swc.js +1 -1
- package/dist/react-sdk/domain-context/index.d.ts +1 -1
- package/dist/react-sdk/domain-context/index.js +1 -1
- package/dist/react-sdk/domain-context/system-hooks/navigation-hooks.d.ts +5 -0
- package/dist/react-sdk/domain-context/system-hooks/navigation-hooks.js +1 -1
- package/dist/ui-sdk/theme/types.d.ts +1 -1
- package/package.json +33 -29
- package/dist/application-spec/generate-application/code/templates/webhook-implementation.d.ts +0 -1
- package/dist/application-spec/generate-application/code/templates/webhook-implementation.js +0 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Custom Layout
|
|
2
2
|
|
|
3
|
-
Make the configurator look like the brand's own product page. A furniture shop wants its table configurator to read like the rest of its site: a floating panel on the left with the product's name, the edge finishes as picture cards, the price in large type and one clear call to action, and the table in the space beside it. The project writes that page as one React component. Phoenix supplies the 3D scene, the product's state and a few hooks; every pixel around the scene is the project's own.
|
|
3
|
+
Make the configurator look like the brand's own product page. A furniture shop wants its table configurator to read like the rest of its site: a floating panel on the left with the product's name, the edge finishes as picture cards, the price in large type and one clear call to action, and the table in the space beside it. On a phone the panel becomes a sheet at the bottom, and a button on the table's edge offers the finishes right where they apply. The project writes that page as one React component. Phoenix supplies the 3D scene, the product's state and a few hooks; every pixel around the scene is the project's own.
|
|
4
4
|
|
|
5
5
|
- [Choose this approach](#choose-this-approach)
|
|
6
6
|
- [Register the layout](#register-the-layout)
|
|
@@ -11,6 +11,8 @@ Make the configurator look like the brand's own product page. A furniture shop w
|
|
|
11
11
|
- [Show messages](#show-messages)
|
|
12
12
|
- [Request a quote](#request-a-quote)
|
|
13
13
|
- [Serve read mode](#serve-read-mode)
|
|
14
|
+
- [Fit a phone](#fit-a-phone)
|
|
15
|
+
- [Put a control on the product](#put-a-control-on-the-product)
|
|
14
16
|
- [The spec the layout reads](#the-spec-the-layout-reads)
|
|
15
17
|
- [Try the example](#try-the-example)
|
|
16
18
|
- [Test cases](#test-cases)
|
|
@@ -32,26 +34,43 @@ Create `src/elements/ConfiguratorLayout.tsx` with a default export made by `phx.
|
|
|
32
34
|
|
|
33
35
|
```tsx
|
|
34
36
|
import { Box } from '@mui/material'
|
|
37
|
+
import { useState } from 'react'
|
|
35
38
|
|
|
36
39
|
import { LayoutSlots, useSceneSafeArea } from '@salsita-ai/sdk/configurator'
|
|
40
|
+
import { useActiveViewports } from '@salsita-ai/sdk/react'
|
|
37
41
|
|
|
38
42
|
import { phx } from '../../.phoenix/phx.js'
|
|
43
|
+
import { BottomSheet } from '../_shared/BottomSheet.js'
|
|
39
44
|
import { FullscreenToggle } from '../_shared/FullscreenToggle.js'
|
|
40
45
|
import { MessageToast } from '../_shared/MessageToast.js'
|
|
41
46
|
import { ProductPanel } from '../_shared/ProductPanel.js'
|
|
42
47
|
|
|
43
48
|
const GUTTER = 24
|
|
44
49
|
const PANEL_WIDTH = 380
|
|
50
|
+
const PHONE_GUTTER = 16
|
|
51
|
+
const TOUCH_TARGET = 44
|
|
45
52
|
|
|
46
53
|
const ConfiguratorLayout = () => {
|
|
47
54
|
const { containerRef, safeAreaRef } = useSceneSafeArea()
|
|
48
55
|
const isFullscreen = phx.frontend.useIsFullscreenActive()
|
|
56
|
+
// `null` until the container has been measured; the layout shows only the scene until then
|
|
57
|
+
const viewports = useActiveViewports()
|
|
58
|
+
const [isSheetOpen, setSheetOpen] = useState(false)
|
|
59
|
+
|
|
60
|
+
const isDesktop = viewports?.includes('desktop') ?? false
|
|
61
|
+
const gutter = isDesktop ? GUTTER : PHONE_GUTTER
|
|
49
62
|
|
|
50
63
|
return (
|
|
51
|
-
<Box
|
|
64
|
+
<Box
|
|
65
|
+
component="div"
|
|
66
|
+
ref={containerRef}
|
|
67
|
+
// `clip`, not `hidden`: what overflows, such as the sheet's fill below its edge, can never be scrolled into view,
|
|
68
|
+
// not even by the browser when the focus moves
|
|
69
|
+
sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'clip' }}
|
|
70
|
+
>
|
|
52
71
|
<LayoutSlots.SharedCanvas />
|
|
53
72
|
|
|
54
|
-
{!isFullscreen && (
|
|
73
|
+
{viewports !== null && isDesktop && !isFullscreen && (
|
|
55
74
|
<Box
|
|
56
75
|
component="aside"
|
|
57
76
|
sx={{
|
|
@@ -65,37 +84,60 @@ const ConfiguratorLayout = () => {
|
|
|
65
84
|
borderRadius: 5,
|
|
66
85
|
bgcolor: 'background.paper',
|
|
67
86
|
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.12)',
|
|
68
|
-
|
|
87
|
+
// A very short container, such as a phone held sideways, scrolls the panel instead of clipping its footer
|
|
88
|
+
overflowX: 'hidden',
|
|
89
|
+
overflowY: 'auto',
|
|
90
|
+
overscrollBehavior: 'contain',
|
|
69
91
|
}}
|
|
70
92
|
>
|
|
71
93
|
<ProductPanel />
|
|
72
94
|
</Box>
|
|
73
95
|
)}
|
|
74
96
|
|
|
75
|
-
|
|
76
|
-
component="div"
|
|
77
|
-
ref={safeAreaRef}
|
|
78
|
-
sx={{
|
|
79
|
-
position: 'absolute',
|
|
80
|
-
insetBlock: 0,
|
|
81
|
-
insetInlineStart: isFullscreen ? 0 : PANEL_WIDTH + 2 * GUTTER,
|
|
82
|
-
insetInlineEnd: 0,
|
|
83
|
-
pointerEvents: 'none',
|
|
84
|
-
}}
|
|
85
|
-
>
|
|
97
|
+
{viewports !== null && (
|
|
86
98
|
<Box
|
|
87
99
|
component="div"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
ref={safeAreaRef}
|
|
101
|
+
sx={{
|
|
102
|
+
// Desktop: right of the panel. Phone: above the sheet, whose height the column gives the rest.
|
|
103
|
+
...(isDesktop
|
|
104
|
+
? {
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
insetBlock: 0,
|
|
107
|
+
insetInlineStart: isFullscreen ? 0 : PANEL_WIDTH + 2 * GUTTER,
|
|
108
|
+
insetInlineEnd: 0,
|
|
109
|
+
}
|
|
110
|
+
: { position: 'relative', flex: '1 1 0', minBlockSize: 0 }),
|
|
111
|
+
pointerEvents: 'none',
|
|
112
|
+
}}
|
|
95
113
|
>
|
|
96
|
-
<
|
|
114
|
+
<Box
|
|
115
|
+
component="div"
|
|
116
|
+
sx={{ position: 'absolute', insetBlockStart: gutter, insetInlineEnd: gutter, pointerEvents: 'auto' }}
|
|
117
|
+
>
|
|
118
|
+
<FullscreenToggle compact={!isDesktop} />
|
|
119
|
+
</Box>
|
|
120
|
+
<Box
|
|
121
|
+
component="div"
|
|
122
|
+
sx={{
|
|
123
|
+
position: 'absolute',
|
|
124
|
+
pointerEvents: 'auto',
|
|
125
|
+
// Desktop: bottom left. Phone: at the top beside the fullscreen button, clear of the sheet's handle.
|
|
126
|
+
...(isDesktop
|
|
127
|
+
? { insetBlockEnd: GUTTER, insetInlineStart: GUTTER }
|
|
128
|
+
: { insetBlockStart: gutter, insetInline: `${gutter}px ${2 * gutter + TOUCH_TARGET}px` }),
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<MessageToast />
|
|
132
|
+
</Box>
|
|
97
133
|
</Box>
|
|
98
|
-
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{viewports !== null && !isDesktop && !isFullscreen && (
|
|
137
|
+
<BottomSheet open={isSheetOpen} onOpenChange={setSheetOpen} label="Product details">
|
|
138
|
+
<ProductPanel collapsed={!isSheetOpen} inSheet />
|
|
139
|
+
</BottomSheet>
|
|
140
|
+
)}
|
|
99
141
|
</Box>
|
|
100
142
|
)
|
|
101
143
|
}
|
|
@@ -110,9 +152,10 @@ The components it uses live in `src/_shared/`, which Phoenix does not import by
|
|
|
110
152
|
- **Position the root and cover the page.** `LayoutSlots.SharedCanvas` is absolutely positioned and fills its nearest positioned ancestor. The root here is `position: absolute; inset: 0`.
|
|
111
153
|
- **Mount the shared canvas.** The app loader stays on screen until the scene has loaded. A layout without `LayoutSlots.SharedCanvas` never gets past the loader unless the project sets `appLoader: legacy`.
|
|
112
154
|
- **Put the UI above the canvas.** The canvas is opaque. Content that is positioned and comes after it in the markup is painted above it; unpositioned content is painted under it and cannot be seen.
|
|
113
|
-
- **Tell the camera where the free area is.** `useSceneSafeArea()` returns two refs. `containerRef` goes on the element that covers the whole canvas, `safeAreaRef` on the region the product should stay visible in, here everything right of the panel. The camera then frames the product in that region. The hook measures again whenever either of the two elements resizes; for a change no resize reports, such as a `transform` on an ancestor settling at the end of an animation, it also returns `update` to call. Without the two refs the product is centred in the whole canvas and sits against the panel.
|
|
155
|
+
- **Tell the camera where the free area is.** `useSceneSafeArea()` returns two refs. `containerRef` goes on the element that covers the whole canvas, `safeAreaRef` on the region the product should stay visible in, here everything right of the panel, or above the sheet on a phone. The camera then frames the product in that region. The hook measures again whenever either of the two elements resizes; for a change no resize reports, such as a `transform` on an ancestor settling at the end of an animation, it also returns `update` to call. Without the two refs the product is centred in the whole canvas and sits against the panel.
|
|
114
156
|
- **Let the pointer through.** The safe-area element lies over the canvas, so it sets `pointerEvents: 'none'`; otherwise the scene cannot be orbited. The fullscreen button inside it switches pointer events back on.
|
|
115
|
-
- **Honour fullscreen.** Fullscreen is a flag in the product's state, not a browser feature. The layout reads it with `phx.frontend.useIsFullscreenActive()`, removes the panel and lets the safe area
|
|
157
|
+
- **Honour fullscreen.** Fullscreen is a flag in the product's state, not a browser feature. The layout reads it with `phx.frontend.useIsFullscreenActive()`, removes the panel or the sheet and lets the safe area cover the canvas, so the camera frames the product in the whole canvas.
|
|
158
|
+
- **Fit the width it is given.** The configurator can be embedded in a narrow column or opened on a phone. The layout reads the container's width class with `useActiveViewports()` and renders a desktop or a phone arrangement, see [Fit a phone](#fit-a-phone).
|
|
116
159
|
- **Serve read mode.** The same element renders a configuration shared for viewing, and the layout decides what a viewer sees.
|
|
117
160
|
|
|
118
161
|
## Read and change the product
|
|
@@ -131,7 +174,14 @@ import { ReviewActions } from './ReviewActions.js'
|
|
|
131
174
|
|
|
132
175
|
const BUTTON_SX = { minBlockSize: 48, borderRadius: 999, textTransform: 'none', fontWeight: 600 } as const
|
|
133
176
|
|
|
134
|
-
|
|
177
|
+
interface ProductPanelProps {
|
|
178
|
+
// Only the name, the price and the action, for a collapsed bottom sheet
|
|
179
|
+
readonly collapsed?: boolean
|
|
180
|
+
// In a bottom sheet the cards region keeps the top of the cards in view, and the sheet scrolls when that does not fit
|
|
181
|
+
readonly inSheet?: boolean
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const ProductPanel = ({ collapsed = false, inSheet = false }: ProductPanelProps) => {
|
|
135
185
|
const resolve = useResolveLocalizable()
|
|
136
186
|
const detail = phx.frontend.useFirstRootComponent(detailComponent)
|
|
137
187
|
const price = phx.frontend.useRootEntityPrice()
|
|
@@ -168,29 +218,44 @@ export const ProductPanel = () => {
|
|
|
168
218
|
<Typography variant="h5" component="h2" fontWeight={700}>
|
|
169
219
|
{resolve(detail?.state.title)}
|
|
170
220
|
</Typography>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<Box component="div" sx={{ display: 'grid', gap: 1.5, paddingInline: 3, paddingBlockEnd: 3, overflowY: 'auto' }}>
|
|
177
|
-
<Typography variant="subtitle2" component="h3">
|
|
178
|
-
Edge finish
|
|
179
|
-
</Typography>
|
|
180
|
-
{isReview ? (
|
|
181
|
-
<Box component="div" sx={{ padding: 2, borderRadius: 3, bgcolor: 'action.hover' }}>
|
|
182
|
-
<Typography variant="body2" fontWeight={600}>
|
|
183
|
-
{resolve(chosenEdge?.title)}
|
|
184
|
-
</Typography>
|
|
185
|
-
<Typography variant="caption" color="text.secondary">
|
|
186
|
-
{resolve(chosenEdge?.description)}
|
|
187
|
-
</Typography>
|
|
188
|
-
</Box>
|
|
189
|
-
) : (
|
|
190
|
-
<EdgeFinishCards selection={edgeFinish} />
|
|
221
|
+
{!collapsed && (
|
|
222
|
+
<Typography variant="body2" color="text.secondary">
|
|
223
|
+
{resolve(detail?.state.description)}
|
|
224
|
+
</Typography>
|
|
191
225
|
)}
|
|
192
226
|
</Box>
|
|
193
227
|
|
|
228
|
+
{!collapsed && (
|
|
229
|
+
<Box
|
|
230
|
+
component="div"
|
|
231
|
+
sx={{
|
|
232
|
+
display: 'grid',
|
|
233
|
+
gap: 1.5,
|
|
234
|
+
paddingInline: 3,
|
|
235
|
+
paddingBlockEnd: 3,
|
|
236
|
+
// In a short container the region shrinks and scrolls; a scroll past its end moves its container on
|
|
237
|
+
minBlockSize: inSheet ? 120 : 0,
|
|
238
|
+
overflowY: 'auto',
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<Typography variant="subtitle2" component="h3">
|
|
242
|
+
Edge finish
|
|
243
|
+
</Typography>
|
|
244
|
+
{isReview ? (
|
|
245
|
+
<Box component="div" sx={{ padding: 2, borderRadius: 3, bgcolor: 'action.hover' }}>
|
|
246
|
+
<Typography variant="body2" fontWeight={600}>
|
|
247
|
+
{resolve(chosenEdge?.title)}
|
|
248
|
+
</Typography>
|
|
249
|
+
<Typography variant="caption" color="text.secondary">
|
|
250
|
+
{resolve(chosenEdge?.description)}
|
|
251
|
+
</Typography>
|
|
252
|
+
</Box>
|
|
253
|
+
) : (
|
|
254
|
+
<EdgeFinishCards selection={edgeFinish} />
|
|
255
|
+
)}
|
|
256
|
+
</Box>
|
|
257
|
+
)}
|
|
258
|
+
|
|
194
259
|
<Box component="footer" sx={{ display: 'grid', gap: 2, padding: 3, borderTop: 1, borderColor: 'divider' }}>
|
|
195
260
|
<Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
|
196
261
|
<Typography variant="body2" color="text.secondary">
|
|
@@ -241,22 +306,23 @@ export const ProductPanel = () => {
|
|
|
241
306
|
}
|
|
242
307
|
```
|
|
243
308
|
|
|
244
|
-
| Hook | What the layout uses it for
|
|
245
|
-
| ---------------------------------------------------------------------------- |
|
|
246
|
-
| `phx.frontend.useFirstRootComponent(detailComponent)` | The `detail` component of the root entity: name, subtitle and description. `detailComponent` comes from `@salsita-ai/sdk/domain`; the hook returns `null` without one.
|
|
247
|
-
| `phx.frontend.useRootEntityId()` | The id of the table, which the control hooks take.
|
|
248
|
-
| `phx.frontend.useAttributeSelection(entityId, instanceName)` | The `attributeSelection` component named `edgeFinish`: the current `value` and the `options`, each with `title`, `description`, `value`, `disabled` and `onSelect`.
|
|
249
|
-
| `phx.frontend.useRootEntityPrice()` | The root entity's `price` component, formatted for the current locale and currency, or `null` without one.
|
|
250
|
-
| `phx.frontend.useAccessService()` | `accessInfo.viewMode` is `true` while a configuration shared for viewing is open, and also while the host sets the `readonly` property.
|
|
251
|
-
| `phx.frontend.useActiveNavigationState()` | The active navigation state; the panel shows its review step while it is `checkout`.
|
|
252
|
-
| `phx.frontend.usePushNavigate()` and `phx.frontend.usePopNavigate()` | Enter the review step and leave it again.
|
|
253
|
-
| `phx.frontend.useNavigate()` | Return from the confirmation to the configuration, forgetting the review step.
|
|
254
|
-
| `phx.frontend.useComponents({ componentTypes: [finalizeActionComponent] })` | The spec's `finalizeAction` components; the panel offers the primary one in its review step. `finalizeActionComponent` comes from `@salsita-ai/sdk/domain`.
|
|
255
|
-
| `phx.frontend.useFinalizeAction(handler)` | Run the finalize action's handler, with its pending state, its error and whether finalizing is possible; see [Request a quote](#request-a-quote).
|
|
256
|
-
| `phx.frontend.useIsFullscreenActive()` and `phx.frontend.useSetFullscreen()` | Read the fullscreen flag, and set it from the layout's own button.
|
|
257
|
-
| `
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
309
|
+
| Hook | What the layout uses it for |
|
|
310
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
311
|
+
| `phx.frontend.useFirstRootComponent(detailComponent)` | The `detail` component of the root entity: name, subtitle and description. `detailComponent` comes from `@salsita-ai/sdk/domain`; the hook returns `null` without one. |
|
|
312
|
+
| `phx.frontend.useRootEntityId()` | The id of the table, which the control hooks take. |
|
|
313
|
+
| `phx.frontend.useAttributeSelection(entityId, instanceName)` | The `attributeSelection` component named `edgeFinish`: the current `value` and the `options`, each with `title`, `description`, `value`, `disabled` and `onSelect`. |
|
|
314
|
+
| `phx.frontend.useRootEntityPrice()` | The root entity's `price` component, formatted for the current locale and currency, or `null` without one. |
|
|
315
|
+
| `phx.frontend.useAccessService()` | `accessInfo.viewMode` is `true` while a configuration shared for viewing is open, and also while the host sets the `readonly` property. |
|
|
316
|
+
| `phx.frontend.useActiveNavigationState()` | The active navigation state; the panel shows its review step while it is `checkout`. |
|
|
317
|
+
| `phx.frontend.usePushNavigate()` and `phx.frontend.usePopNavigate()` | Enter the review step and leave it again. |
|
|
318
|
+
| `phx.frontend.useNavigate()` | Return from the confirmation to the configuration, forgetting the review step. |
|
|
319
|
+
| `phx.frontend.useComponents({ componentTypes: [finalizeActionComponent] })` | The spec's `finalizeAction` components; the panel offers the primary one in its review step. `finalizeActionComponent` comes from `@salsita-ai/sdk/domain`. |
|
|
320
|
+
| `phx.frontend.useFinalizeAction(handler)` | Run the finalize action's handler, with its pending state, its error and whether finalizing is possible; see [Request a quote](#request-a-quote). |
|
|
321
|
+
| `phx.frontend.useIsFullscreenActive()` and `phx.frontend.useSetFullscreen()` | Read the fullscreen flag, and set it from the layout's own button. |
|
|
322
|
+
| `useActiveViewports()` from `@salsita-ai/sdk/react` | The width classes of the configurator's container, or `null` before it is measured; the layout renders its phone arrangement unless it includes `desktop`, see [Fit a phone](#fit-a-phone). |
|
|
323
|
+
| `phx.frontend.useConfiguratorMessages(onMessage)` | Receive violations, side-effect hints and errors one by one, with their text resolved, for the layout's own toast; see [Show messages](#show-messages). |
|
|
324
|
+
| `useResolveLocalizable()` from `@salsita-ai/sdk/react` | Turns the localizable texts of the spec into strings in the current language. [Localization](?path=/docs/phoenix-localization--docs) describes it and `LocalizableMessage`. |
|
|
325
|
+
| `PhoenixHandlerBoundary` from `@salsita-ai/sdk/react` | Wraps the review step's buttons, so an edited handler takes effect without a reload. |
|
|
260
326
|
|
|
261
327
|
Calling an option's `onSelect` is the whole write: it changes `edgeFinish` on the table, the scene morphs the edge and the price expression recalculates. The option fields are named for display: the spec's `label` arrives as `title`, its `img` as `imageSrc`.
|
|
262
328
|
|
|
@@ -406,39 +472,50 @@ The drawings are keyed by the option values of the spec. A spec can carry a pict
|
|
|
406
472
|
Fullscreen is a flag in the configurator's state, not the browser's fullscreen mode. `phx.frontend.useIsFullscreenActive()` reads it and `phx.frontend.useSetFullscreen()` returns a function that sets it: `setFullscreen(true)` turns it on, `setFullscreen(false)` off. The layout's own button flips it, and the layout decides what to hide.
|
|
407
473
|
|
|
408
474
|
```tsx
|
|
409
|
-
import { Button } from '@mui/material'
|
|
475
|
+
import { Button, Tooltip } from '@mui/material'
|
|
410
476
|
|
|
411
477
|
import { phx } from '../../.phoenix/phx.js'
|
|
412
478
|
|
|
413
479
|
const ENTER_ICON = 'M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'
|
|
414
480
|
const EXIT_ICON = 'M9 4v5H4M15 4v5h5M15 20v-5h5M9 20v-5H4'
|
|
415
481
|
|
|
416
|
-
|
|
482
|
+
interface FullscreenToggleProps {
|
|
483
|
+
// Only the icon, with the label as its accessible name and tooltip, where a label would cover the product
|
|
484
|
+
readonly compact?: boolean
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export const FullscreenToggle = ({ compact = false }: FullscreenToggleProps) => {
|
|
417
488
|
const isFullscreen = phx.frontend.useIsFullscreenActive()
|
|
418
489
|
const setFullscreen = phx.frontend.useSetFullscreen()
|
|
490
|
+
const label = isFullscreen ? 'Exit fullscreen' : 'Fullscreen'
|
|
491
|
+
const icon = (
|
|
492
|
+
<svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={2}>
|
|
493
|
+
<path d={isFullscreen ? EXIT_ICON : ENTER_ICON} />
|
|
494
|
+
</svg>
|
|
495
|
+
)
|
|
419
496
|
|
|
420
497
|
return (
|
|
421
|
-
<
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
</
|
|
498
|
+
<Tooltip title={compact ? label : ''}>
|
|
499
|
+
<Button
|
|
500
|
+
onClick={() => setFullscreen(!isFullscreen)}
|
|
501
|
+
aria-label={compact ? label : undefined}
|
|
502
|
+
startIcon={compact ? undefined : icon}
|
|
503
|
+
sx={{
|
|
504
|
+
minBlockSize: 44,
|
|
505
|
+
minInlineSize: 44,
|
|
506
|
+
paddingInline: compact ? 0 : 2.5,
|
|
507
|
+
borderRadius: 999,
|
|
508
|
+
bgcolor: 'background.paper',
|
|
509
|
+
color: 'text.primary',
|
|
510
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
|
511
|
+
textTransform: 'none',
|
|
512
|
+
fontWeight: 600,
|
|
513
|
+
'&:hover': { bgcolor: 'background.paper', boxShadow: '0 6px 20px rgba(0, 0, 0, 0.18)' },
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{compact ? icon : label}
|
|
517
|
+
</Button>
|
|
518
|
+
</Tooltip>
|
|
442
519
|
)
|
|
443
520
|
}
|
|
444
521
|
```
|
|
@@ -447,7 +524,7 @@ The button stays in the free area in both states, so the way back is always wher
|
|
|
447
524
|
|
|
448
525
|
## Show messages
|
|
449
526
|
|
|
450
|
-
When a change is rejected because it breaks an invariant, when a committed change carries a hint, or when an error is thrown while a change is validated, the configurator has a message for the customer. `phx.frontend.useConfiguratorMessages(onMessage)` calls `onMessage` once for each, with the text already in the customer's language, and the layout shows it its own way. Here a toast at the bottom of the free area
|
|
527
|
+
When a change is rejected because it breaks an invariant, when a committed change carries a hint, or when an error is thrown while a change is validated, the configurator has a message for the customer. `phx.frontend.useConfiguratorMessages(onMessage)` calls `onMessage` once for each, with the text already in the customer's language, and the layout shows it its own way. Here a toast shows a message for six seconds, colored by its severity, at the bottom of the free area on a desktop and at its top on a phone:
|
|
451
528
|
|
|
452
529
|
```tsx
|
|
453
530
|
import { Alert } from '@mui/material'
|
|
@@ -581,7 +658,9 @@ interface ReviewActionsProps {
|
|
|
581
658
|
|
|
582
659
|
// The review step's buttons: one finalize action and the way back. `useFinalizeAction` runs the action's handler,
|
|
583
660
|
// so this component calls it exactly once and is rendered inside a `PhoenixHandlerBoundary` for that handler.
|
|
584
|
-
// Back is disabled while the request is pending, so the
|
|
661
|
+
// Back is disabled while the request is pending, so the customer cannot change the configuration while it is sent.
|
|
662
|
+
// The pending state belongs to this component: when the panel unmounts, as in fullscreen, the request goes on and
|
|
663
|
+
// the remounted buttons are enabled again.
|
|
585
664
|
export const ReviewActions = ({ handler, label, onBack }: ReviewActionsProps) => {
|
|
586
665
|
const { run, isPending, error, disabled, hints } = phx.frontend.useFinalizeAction(handler)
|
|
587
666
|
|
|
@@ -614,7 +693,7 @@ export const ReviewActions = ({ handler, label, onBack }: ReviewActionsProps) =>
|
|
|
614
693
|
```
|
|
615
694
|
|
|
616
695
|
- `run()` records the finalize-button click for analytics and runs the handler; it does nothing while `disabled` or `isPending`.
|
|
617
|
-
- While `isPending` the button shows a spinner and cannot be clicked again, and **Back to the configuration** is disabled:
|
|
696
|
+
- While `isPending` the button shows a spinner and cannot be clicked again, and **Back to the configuration** is disabled, so the customer cannot change the configuration while it is sent. `isPending` and `error` belong to the mounted component: entering fullscreen unmounts the panel, the request goes on, and the panel shown after fullscreen starts with both buttons enabled. A layout that must rule that out keeps the review step mounted, or hides its fullscreen button while a request runs.
|
|
618
697
|
- `error` is what the handler rejected with. The layout shows it under the button; no snackbar is raised. It is cleared when the customer tries again.
|
|
619
698
|
- `disabled` and `hints` come from the configuration's violations: while a `warning` or worse holds, or the configuration failed to save, finalizing is not possible and `hints` says why.
|
|
620
699
|
|
|
@@ -622,7 +701,319 @@ The review step stays: a quote request is a commitment, so the customer sees the
|
|
|
622
701
|
|
|
623
702
|
## Serve read mode
|
|
624
703
|
|
|
625
|
-
While `accessInfo.viewMode` is `true` the panel marks the view as view only, replaces the cards with the chosen finish and shows the price without an action; the finalize action is not offered, since `useFinalizeAction` does not check read mode itself. The hooks do not hide or disable anything in read mode: what a viewer can change is decided by what the layout renders. Rotating and zooming the scene, and fullscreen, stay available, as [Interaction Modes](?path=/docs/ux-guidelines-interaction-modes--docs#view-only-mode) recommends.
|
|
704
|
+
While `accessInfo.viewMode` is `true` the panel marks the view as view only, replaces the cards with the chosen finish and shows the price without an action; the finalize action is not offered, since `useFinalizeAction` does not check read mode itself. The hooks do not hide or disable anything in read mode: what a viewer can change is decided by what the layout renders. Rotating and zooming the scene, and fullscreen, stay available, as [Interaction Modes](?path=/docs/ux-guidelines-interaction-modes--docs#view-only-mode) recommends. The button on the table's edge is hidden for viewers by the spec, see [Put a control on the product](#put-a-control-on-the-product).
|
|
705
|
+
|
|
706
|
+
## Fit a phone
|
|
707
|
+
|
|
708
|
+
A panel 380 pixels wide beside the scene leaves nothing of the table on a phone. Below a desktop width the layout puts the panel into a sheet at the bottom and lets the camera frame the table in the area above it. The layout above shows both arrangements.
|
|
709
|
+
|
|
710
|
+
**The switch.** `useActiveViewports()` from `@salsita-ai/sdk/react` returns the width classes the configurator's container satisfies. The container decides, not the window, so a configurator embedded in a narrow column of a wide page gets the phone arrangement too. The built-in layout switches on `desktop` as well.
|
|
711
|
+
|
|
712
|
+
| Value | When it is active |
|
|
713
|
+
| ----------- | ---------------------------------------------------------------------------------- |
|
|
714
|
+
| `phone` | The container is narrower than 480 pixels. |
|
|
715
|
+
| `mobile` | The container is at most 768 pixels wide. |
|
|
716
|
+
| `tablet` | The container is wider than 480 and narrower than 1024 pixels. |
|
|
717
|
+
| `desktop` | The container is wider than 768 pixels; `mobile` and `desktop` exclude each other. |
|
|
718
|
+
| `landscape` | The browser window, not the container, is wider than it is high. |
|
|
719
|
+
| `portrait` | The browser window, not the container, is higher than it is wide. |
|
|
720
|
+
|
|
721
|
+
The hook returns `null` until the container has been measured, and the layout renders only the scene until then, so a phone never shows the desktop panel for a frame.
|
|
722
|
+
|
|
723
|
+
**The sheet.** On a phone the layout is a column: the safe-area element takes the height the sheet leaves, and the sheet sits below it. The sheet has two positions. Collapsed, it shows the name, the price and the action; open, it shows the description and the cards too. The `collapsed` prop of the panel hides the description and the cards, and nothing else, and `inSheet` keeps the top of the cards in view. The sheet scrolls what does not fit, so the price, the review step, **Request a quote** and the thank-you message are reachable in both positions.
|
|
724
|
+
|
|
725
|
+
```tsx
|
|
726
|
+
import { Box, ButtonBase } from '@mui/material'
|
|
727
|
+
import { type MouseEvent, type PointerEvent, type ReactNode, useRef } from 'react'
|
|
728
|
+
|
|
729
|
+
// A drag shorter than this is a tap, and the handle's click toggles the sheet
|
|
730
|
+
const TAP_DISTANCE = 6
|
|
731
|
+
// A drag that ends this far from where it started snaps to the other position
|
|
732
|
+
const SNAP_DISTANCE = 48
|
|
733
|
+
// A drag faster than this, in pixels per millisecond, snaps in its direction even when it is short
|
|
734
|
+
const SNAP_VELOCITY = 0.5
|
|
735
|
+
// How far the sheet follows the finger towards the position it is already in
|
|
736
|
+
const OVERDRAG = 0.25
|
|
737
|
+
|
|
738
|
+
interface BottomSheetProps {
|
|
739
|
+
readonly open: boolean
|
|
740
|
+
readonly onOpenChange: (open: boolean) => void
|
|
741
|
+
readonly label: string
|
|
742
|
+
readonly children: ReactNode
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
interface Drag {
|
|
746
|
+
readonly startY: number
|
|
747
|
+
readonly startTime: number
|
|
748
|
+
delta: number
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// A persistent sheet at the bottom of the layout with two positions, open and collapsed. It is not a modal: the scene
|
|
752
|
+
// above it stays interactive. Its height comes from its content, so the layout's safe area ends where the sheet
|
|
753
|
+
// starts and changes only when the sheet snaps. While the handle is dragged the sheet follows the finger with a
|
|
754
|
+
// transform, which the safe area does not see, so the camera refits once per snap and not on every move.
|
|
755
|
+
export const BottomSheet = ({ open, onOpenChange, label, children }: BottomSheetProps) => {
|
|
756
|
+
const sheetRef = useRef<HTMLElement>(null)
|
|
757
|
+
const dragRef = useRef<Drag | null>(null)
|
|
758
|
+
const draggedRef = useRef(false)
|
|
759
|
+
|
|
760
|
+
const moveSheet = (offset: number, animate: boolean) => {
|
|
761
|
+
if (sheetRef.current) {
|
|
762
|
+
sheetRef.current.style.transition = animate ? 'transform 200ms ease-out' : 'none'
|
|
763
|
+
sheetRef.current.style.transform = offset === 0 ? '' : `translateY(${offset}px)`
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
|
768
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
769
|
+
dragRef.current = { startY: event.clientY, startTime: event.timeStamp, delta: 0 }
|
|
770
|
+
draggedRef.current = false
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
|
774
|
+
const drag = dragRef.current
|
|
775
|
+
|
|
776
|
+
if (drag === null) {
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
drag.delta = event.clientY - drag.startY
|
|
781
|
+
// Down closes an open sheet and up opens a collapsed one; the other direction only gives a little
|
|
782
|
+
const towardsOther = open ? drag.delta > 0 : drag.delta < 0
|
|
783
|
+
moveSheet(towardsOther ? drag.delta : drag.delta * OVERDRAG, false)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const handlePointerEnd = (event: PointerEvent<HTMLButtonElement>) => {
|
|
787
|
+
const drag = dragRef.current
|
|
788
|
+
dragRef.current = null
|
|
789
|
+
|
|
790
|
+
if (drag === null) {
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
moveSheet(0, true)
|
|
795
|
+
|
|
796
|
+
if (Math.abs(drag.delta) < TAP_DISTANCE) {
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
draggedRef.current = true
|
|
801
|
+
const velocity = drag.delta / Math.max(event.timeStamp - drag.startTime, 1)
|
|
802
|
+
const flicked = Math.abs(drag.delta) >= SNAP_DISTANCE || Math.abs(velocity) >= SNAP_VELOCITY
|
|
803
|
+
|
|
804
|
+
if (flicked && (open ? drag.delta > 0 : drag.delta < 0)) {
|
|
805
|
+
onOpenChange(!open)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// A tap, Enter or Space toggles the sheet. The pointer click that ends a drag has already been handled; a keyboard
|
|
810
|
+
// click (`detail` 0) always toggles, also after a touch drag that was followed by no click.
|
|
811
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
812
|
+
const dragged = draggedRef.current
|
|
813
|
+
draggedRef.current = false
|
|
814
|
+
|
|
815
|
+
if (dragged && event.detail !== 0) {
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
onOpenChange(!open)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return (
|
|
823
|
+
<Box
|
|
824
|
+
component="section"
|
|
825
|
+
ref={sheetRef}
|
|
826
|
+
aria-label={label}
|
|
827
|
+
sx={{
|
|
828
|
+
position: 'relative',
|
|
829
|
+
flexShrink: 0,
|
|
830
|
+
display: 'flex',
|
|
831
|
+
flexDirection: 'column',
|
|
832
|
+
maxBlockSize: '55%',
|
|
833
|
+
paddingBlockEnd: 'env(safe-area-inset-bottom)',
|
|
834
|
+
borderStartStartRadius: 20,
|
|
835
|
+
borderStartEndRadius: 20,
|
|
836
|
+
bgcolor: 'background.paper',
|
|
837
|
+
boxShadow: '0 -8px 32px rgba(0, 0, 0, 0.12)',
|
|
838
|
+
// Fills the gap below the sheet while it is dragged up
|
|
839
|
+
'&::after': {
|
|
840
|
+
content: '""',
|
|
841
|
+
position: 'absolute',
|
|
842
|
+
insetInline: 0,
|
|
843
|
+
insetBlockStart: '100%',
|
|
844
|
+
blockSize: '100vh',
|
|
845
|
+
bgcolor: 'background.paper',
|
|
846
|
+
},
|
|
847
|
+
}}
|
|
848
|
+
>
|
|
849
|
+
<ButtonBase
|
|
850
|
+
aria-expanded={open}
|
|
851
|
+
disableRipple
|
|
852
|
+
aria-label={open ? `Collapse ${label.toLowerCase()}` : `Expand ${label.toLowerCase()}`}
|
|
853
|
+
onClick={handleClick}
|
|
854
|
+
onPointerDown={handlePointerDown}
|
|
855
|
+
onPointerMove={handlePointerMove}
|
|
856
|
+
onPointerUp={handlePointerEnd}
|
|
857
|
+
onPointerCancel={handlePointerEnd}
|
|
858
|
+
sx={{
|
|
859
|
+
flexShrink: 0,
|
|
860
|
+
blockSize: 36,
|
|
861
|
+
touchAction: 'none',
|
|
862
|
+
'&.Mui-focusVisible': { outline: 2, outlineColor: 'primary.main', outlineOffset: -4, borderRadius: 3 },
|
|
863
|
+
}}
|
|
864
|
+
>
|
|
865
|
+
<Box component="span" sx={{ inlineSize: 40, blockSize: 4, borderRadius: 2, bgcolor: 'text.disabled' }} />
|
|
866
|
+
</ButtonBase>
|
|
867
|
+
{/* A column, so the panel's cards region shrinks first and the price and action stay in view; below the cards
|
|
868
|
+
region's minimum height the content scrolls as a whole, as the review step does on a short phone */}
|
|
869
|
+
<Box
|
|
870
|
+
component="div"
|
|
871
|
+
sx={{
|
|
872
|
+
display: 'flex',
|
|
873
|
+
flexDirection: 'column',
|
|
874
|
+
flex: '1 1 auto',
|
|
875
|
+
minBlockSize: 0,
|
|
876
|
+
overflowY: 'auto',
|
|
877
|
+
overscrollBehavior: 'contain',
|
|
878
|
+
}}
|
|
879
|
+
>
|
|
880
|
+
{children}
|
|
881
|
+
</Box>
|
|
882
|
+
</Box>
|
|
883
|
+
)
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
- The handle is a real button with `aria-expanded`. A tap, Enter or Space toggles the sheet, and dragging it up or down snaps the sheet to the other position when the drag is long or fast enough. `touchAction: 'none'` is set on the handle only, so the cards still scroll by touch.
|
|
888
|
+
- The sheet's height comes from its content and is at most 55% of the configurator; when the content does not fit, the cards region shrinks until only the top of the cards shows and scrolls, and below that the sheet's content scrolls as a whole. On a phone with room for the header, the top of the cards and the footer, the price and the action stay in view with the sheet open; on a shorter one the customer scrolls the sheet to them, and the review step's buttons are reached the same way in both positions. A scroll on the cards that reaches their end moves the sheet on. Because the safe-area element is its neighbour in the column, the free area ends where the sheet starts without anything being measured by the layout.
|
|
889
|
+
- While the handle is dragged the sheet follows the finger with a `transform`. `useSceneSafeArea()` does not see a transform, so the free area changes once, when the sheet snaps, and the camera refits once. A safe area that followed the finger would refit the camera on every move and reset an orbit the customer made.
|
|
890
|
+
- The sheet is not a modal: no backdrop covers the scene, so the table can be orbited with one finger and zoomed with two while the sheet is open. MUI's temporary `Drawer` and `SwipeableDrawer` are modals and would block the scene; their persistent and permanent variants have no backdrop, but no drag handle with two positions either.
|
|
891
|
+
|
|
892
|
+
**What else moves.** The toast sits at the top of the free area, so it does not cover the sheet's handle, and the fullscreen button shows only its icon, with its label as tooltip and accessible name, so the toast has room beside it. Fullscreen removes the sheet as it removes the panel on a desktop. The sheet keeps its position while fullscreen is on.
|
|
893
|
+
|
|
894
|
+
**A camera for the phone.** The desktop camera is close enough to fill a wide free area, and on a narrow one it cuts off the ends of the table. So each camera has a filter on the viewport: the desktop camera `main` applies while `desktop` is active, and a second camera, `phone`, with a larger distance frames the whole table while `mobile` is. The filters exclude each other once the container is measured. Before that, for example when an image of the configuration is rendered, no viewport is known and a viewport filter matches, so both cameras apply and the higher priority of `main` decides:
|
|
895
|
+
|
|
896
|
+
```yaml
|
|
897
|
+
components:
|
|
898
|
+
Table:
|
|
899
|
+
fitRootPerspectiveCamera:
|
|
900
|
+
main:
|
|
901
|
+
priority: 2
|
|
902
|
+
fov: 35
|
|
903
|
+
near: 100
|
|
904
|
+
far: 10000
|
|
905
|
+
minDistance: 1000
|
|
906
|
+
maxDistance: 9000
|
|
907
|
+
distance: 5000
|
|
908
|
+
azimuthAngle: 1.5
|
|
909
|
+
polarAngle: 1.1
|
|
910
|
+
fitMeshChanges: true
|
|
911
|
+
$filter:
|
|
912
|
+
viewports: [desktop]
|
|
913
|
+
phone:
|
|
914
|
+
priority: 1
|
|
915
|
+
fov: 35
|
|
916
|
+
near: 100
|
|
917
|
+
far: 10000
|
|
918
|
+
minDistance: 1000
|
|
919
|
+
maxDistance: 12000
|
|
920
|
+
distance: 8000
|
|
921
|
+
azimuthAngle: 1.5
|
|
922
|
+
polarAngle: 1.1
|
|
923
|
+
fitMeshChanges: true
|
|
924
|
+
$filter:
|
|
925
|
+
viewports: [mobile]
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
## Put a control on the product
|
|
929
|
+
|
|
930
|
+
The edge finish is a detail of the tabletop, so the layout also offers it on the table itself: a round button on the middle of the long edge opens the four finishes next to the edge. On a phone it is a way to change the edge without opening the sheet.
|
|
931
|
+
|
|
932
|
+
A control in the scene is a spec component that names an element of the project. `htmlHotspot` places an HTML element at an offset from the entity's origin, in the model's units, here millimetres: half the table's depth towards the camera and just below the top's surface. Its `$filter` shows it only to the owner and only while the `configure` navigation state is active, so a viewer and the review step do not get it, and the element does not check either itself:
|
|
933
|
+
|
|
934
|
+
```yaml
|
|
935
|
+
components:
|
|
936
|
+
Table:
|
|
937
|
+
htmlHotspot:
|
|
938
|
+
edgeFinish:
|
|
939
|
+
name: EdgeFinishHotspot
|
|
940
|
+
offsetX: 500
|
|
941
|
+
offsetY: 800
|
|
942
|
+
offsetZ: 0
|
|
943
|
+
$filter:
|
|
944
|
+
modes: [owner]
|
|
945
|
+
navigationStates: [configure]
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
The element is registered with `phx.frontend.createCustomPhoenixEntityElement` under the name the spec gives. It receives the id of the entity the hotspot belongs to, and reads the same `attributeSelection` the panel reads:
|
|
949
|
+
|
|
950
|
+
```tsx
|
|
951
|
+
import { Box, ButtonBase, Popover, Tooltip } from '@mui/material'
|
|
952
|
+
import { useState } from 'react'
|
|
953
|
+
|
|
954
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
955
|
+
import { EdgeFinishCards } from '../_shared/EdgeFinishCards.js'
|
|
956
|
+
|
|
957
|
+
// The edge profile the button shows, the square edge of the cards' drawings
|
|
958
|
+
const EDGE_ICON = 'M3 7H17L21 11V13L17 17H3Z'
|
|
959
|
+
|
|
960
|
+
// Rendered by the spec's `htmlHotspot` on the table's edge: a button on the product that opens the edge finishes
|
|
961
|
+
// next to the edge, with the same cards as the panel. The spec's `$filter` decides when it is shown.
|
|
962
|
+
export default phx.frontend.createCustomPhoenixEntityElement('EdgeFinishHotspot', ({ entityId }) => {
|
|
963
|
+
const edgeFinish = phx.frontend.useAttributeSelection(entityId, 'edgeFinish')
|
|
964
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
|
|
965
|
+
|
|
966
|
+
return (
|
|
967
|
+
<>
|
|
968
|
+
<Tooltip title="Edge finish">
|
|
969
|
+
<ButtonBase
|
|
970
|
+
aria-label="Edge finish"
|
|
971
|
+
aria-haspopup="dialog"
|
|
972
|
+
aria-expanded={anchor !== null}
|
|
973
|
+
onClick={event => setAnchor(event.currentTarget)}
|
|
974
|
+
sx={{
|
|
975
|
+
display: 'grid',
|
|
976
|
+
placeItems: 'center',
|
|
977
|
+
inlineSize: 44,
|
|
978
|
+
blockSize: 44,
|
|
979
|
+
borderRadius: '50%',
|
|
980
|
+
bgcolor: 'background.paper',
|
|
981
|
+
color: 'primary.main',
|
|
982
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)',
|
|
983
|
+
'&.Mui-focusVisible': { outline: 2, outlineColor: 'primary.main', outlineOffset: 2 },
|
|
984
|
+
}}
|
|
985
|
+
>
|
|
986
|
+
<svg viewBox="0 0 24 24" width={22} height={22} aria-hidden>
|
|
987
|
+
<path d={EDGE_ICON} fill="currentColor" />
|
|
988
|
+
</svg>
|
|
989
|
+
</ButtonBase>
|
|
990
|
+
</Tooltip>
|
|
991
|
+
<Popover
|
|
992
|
+
open={anchor !== null}
|
|
993
|
+
anchorEl={anchor}
|
|
994
|
+
onClose={() => setAnchor(null)}
|
|
995
|
+
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
996
|
+
transformOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
997
|
+
slotProps={{
|
|
998
|
+
paper: { role: 'dialog', 'aria-label': 'Edge finish', sx: { borderRadius: 4, marginBlockStart: -1 } },
|
|
999
|
+
}}
|
|
1000
|
+
>
|
|
1001
|
+
<Box component="div" sx={{ inlineSize: 340, maxInlineSize: 'calc(100vw - 32px)', padding: 2 }}>
|
|
1002
|
+
<EdgeFinishCards selection={edgeFinish} />
|
|
1003
|
+
</Box>
|
|
1004
|
+
</Popover>
|
|
1005
|
+
</>
|
|
1006
|
+
)
|
|
1007
|
+
})
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
- The hotspot is rendered inside the scene, but it has the theme and every `phx.frontend` hook, like the layout. MUI's `Popover` and `Tooltip` render into the configurator's own modal root, so they are styled and work inside its shadow root.
|
|
1011
|
+
- The cards in the popover are the panel's `EdgeFinishCards`, used a second time. A choice changes the edge, the price and the panel's cards at once, and the popover stays open so the customer can compare the finishes on the table.
|
|
1012
|
+
- Keyboard: the button is in the tab order, Enter and Space open the popover and move the focus into it, the arrow keys change the finish, and Escape closes it and returns the focus to the button.
|
|
1013
|
+
- `controlHotspot: true` would hide the hotspot while a change is being applied, which would also unmount the open popover on every choice, so this hotspot does not set it.
|
|
1014
|
+
- The scene's HTML is painted inside the canvas's stacking context, under the layout's panel and sheet. A hotspot that a panel covers cannot be clicked there.
|
|
1015
|
+
|
|
1016
|
+
[Html Hotspots](?path=/docs/controls-scene-html-hotspots--docs) describes the component and its other properties.
|
|
626
1017
|
|
|
627
1018
|
## The spec the layout reads
|
|
628
1019
|
|
|
@@ -680,6 +1071,8 @@ transformers:
|
|
|
680
1071
|
|
|
681
1072
|
To see read mode, open **Mocks** in the editor rail on the left and apply the **Read User** template.
|
|
682
1073
|
|
|
1074
|
+
The same layout in a frame 390 pixels wide, as on a phone. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
|
|
1075
|
+
|
|
683
1076
|
## Test cases
|
|
684
1077
|
|
|
685
1078
|
- The panel is on the left and the table is centred in the area to the right of it, not in the whole canvas.
|
|
@@ -691,15 +1084,22 @@ To see read mode, open **Mocks** in the editor rail on the left and apply the **
|
|
|
691
1084
|
- **Request a quote** shows a spinner and "Sending the request…" for about a second and a half, with **Back to the configuration** disabled, then "Thank you. Your quote request is on its way, and the shop replies within two working days."; **Back to the configuration** then returns to the cards.
|
|
692
1085
|
- **Fullscreen** removes the panel and the table moves to the centre of the canvas; **Exit fullscreen** brings both back.
|
|
693
1086
|
- With the **Read User** mock the panel says **View only**, shows the chosen finish and the price, and has no button, so no quote can be requested.
|
|
1087
|
+
- A round button sits on the middle of the table's long edge. Clicking it opens the four finishes next to it; choosing **Round** there moves the check mark in the popover and in the panel, rounds the edge and raises the price to $1,410.00. Escape closes the popover.
|
|
1088
|
+
- Tab reaches the button on the edge, and Enter or Space opens the popover.
|
|
1089
|
+
- The button on the edge is gone in the review step, in the confirmation and with the **Read User** mock, and stays in fullscreen.
|
|
1090
|
+
- In the **Phone** story the panel is a sheet at the bottom showing the name, the price and **Review and request a quote**, and the whole table is visible above it.
|
|
1091
|
+
- Tapping the sheet's handle, or dragging it up, opens the sheet with the cards; the table moves up once the sheet has snapped and stays fully visible. Dragging the handle down, or tapping it again, collapses the sheet.
|
|
1092
|
+
- On the phone, the review step, **Request a quote** and the thank-you message work with the sheet collapsed and open.
|
|
1093
|
+
- On the phone, choosing **Tree trunk** shows the hint at the top of the free area, beside the fullscreen button.
|
|
1094
|
+
- On the phone, the fullscreen button removes the sheet and the table moves to the centre of the canvas; pressing it again brings the sheet back in the position it had.
|
|
694
1095
|
|
|
695
1096
|
## Known gaps
|
|
696
1097
|
|
|
697
1098
|
A project layout has only what it renders. This one leaves out the following, each of which a product may need. [Layout Slots](?path=/docs/phoenix-layout-slots--docs) describes every `LayoutSlots` member named here.
|
|
698
1099
|
|
|
699
|
-
- **Phone width.** The panel is written for desktop: on a narrow screen it covers the scene. A phone layout puts the panel in a bottom sheet and moves the safe area above it.
|
|
700
1100
|
- **Messages that stay snackbars.** The toast shows violations, side effects and errors, and the finalize button shows its own error. A failure to save the configuration is still raised as a snackbar, which renders into `LayoutSlots.DesktopSnackbarsRoot`; this layout mounts no snackbar root, so it appears outside the configurator. A layout that wants it inside mounts the root.
|
|
701
1101
|
- **Toolbar actions.** Undo and redo, camera reset, measurements, sharing and language switching are not offered. A layout can mount `LayoutSlots.DesktopToolbar` for the toolbar the spec configures, or build its own buttons.
|
|
702
|
-
- **View controls, tool controls and leaving a tool mode.** A spec can configure view controls and tool controls, and a tool mode has a button that leaves it. The built-in layout places them with `LayoutSlots.DesktopViewControls`, `LayoutSlots.DesktopToolControls` and `LayoutSlots.DesktopCallToAction`; this layout mounts none of them, so they do not appear.
|
|
1102
|
+
- **View controls, tool controls and leaving a tool mode.** A spec can configure view controls and tool controls, and a tool mode has a button that leaves it. The built-in layout places them with `LayoutSlots.DesktopViewControls`, `LayoutSlots.DesktopToolControls` and `LayoutSlots.DesktopCallToAction`; this layout mounts none of them, so they do not appear. Nor does this layout build view buttons or a camera reset of its own.
|
|
703
1103
|
- **A project's layout dialog.** A dialog the project registers with `phx.frontend.createLayoutDialog` is shown by `LayoutDialog`, which the built-in layout mounts. This layout does not; a layout that needs the dialog mounts `LayoutDialog` from `@salsita-ai/sdk/configurator`.
|
|
704
1104
|
- **Selection.** The table has no parts to select. A product with selectable parts reads `phx.frontend.useSelectedDomainEntityId()` and shows the controls of the selected part.
|
|
705
1105
|
- **Clone to edit in read mode.** With `shareCloneToEdit` set, the built-in layout's `LayoutSlots.DesktopReadonlyBanner` offers **Edit configuration** to a viewer; this layout's "View only" label offers nothing. A layout mounts the banner, or builds its own action with `LayoutSlots.useIsCloningAllowed()` and `LayoutSlots.CloneToEditDialog`.
|
|
@@ -793,9 +1193,18 @@ components:
|
|
|
793
1193
|
title: Send the configuration to the shop for a quote
|
|
794
1194
|
icon: PaperPlaneTilt
|
|
795
1195
|
handler: requestQuote
|
|
1196
|
+
htmlHotspot:
|
|
1197
|
+
edgeFinish:
|
|
1198
|
+
name: EdgeFinishHotspot
|
|
1199
|
+
offsetX: 500
|
|
1200
|
+
offsetY: 800
|
|
1201
|
+
offsetZ: 0
|
|
1202
|
+
$filter:
|
|
1203
|
+
modes: [owner]
|
|
1204
|
+
navigationStates: [configure]
|
|
796
1205
|
fitRootPerspectiveCamera:
|
|
797
1206
|
main:
|
|
798
|
-
priority:
|
|
1207
|
+
priority: 2
|
|
799
1208
|
fov: 35
|
|
800
1209
|
near: 100
|
|
801
1210
|
far: 10000
|
|
@@ -805,6 +1214,21 @@ components:
|
|
|
805
1214
|
azimuthAngle: 1.5
|
|
806
1215
|
polarAngle: 1.1
|
|
807
1216
|
fitMeshChanges: true
|
|
1217
|
+
$filter:
|
|
1218
|
+
viewports: [desktop]
|
|
1219
|
+
phone:
|
|
1220
|
+
priority: 1
|
|
1221
|
+
fov: 35
|
|
1222
|
+
near: 100
|
|
1223
|
+
far: 10000
|
|
1224
|
+
minDistance: 1000
|
|
1225
|
+
maxDistance: 12000
|
|
1226
|
+
distance: 8000
|
|
1227
|
+
azimuthAngle: 1.5
|
|
1228
|
+
polarAngle: 1.1
|
|
1229
|
+
fitMeshChanges: true
|
|
1230
|
+
$filter:
|
|
1231
|
+
viewports: [mobile]
|
|
808
1232
|
sceneEntityRemoteObject:
|
|
809
1233
|
top:
|
|
810
1234
|
fileUrl: /models/tables.gltf
|
|
@@ -852,6 +1276,170 @@ transformers:
|
|
|
852
1276
|
- name: tree-trunk-delivery
|
|
853
1277
|
```
|
|
854
1278
|
|
|
1279
|
+
### ./src/\_shared/BottomSheet.tsx
|
|
1280
|
+
|
|
1281
|
+
```tsx
|
|
1282
|
+
import { Box, ButtonBase } from '@mui/material'
|
|
1283
|
+
import { type MouseEvent, type PointerEvent, type ReactNode, useRef } from 'react'
|
|
1284
|
+
|
|
1285
|
+
// A drag shorter than this is a tap, and the handle's click toggles the sheet
|
|
1286
|
+
const TAP_DISTANCE = 6
|
|
1287
|
+
// A drag that ends this far from where it started snaps to the other position
|
|
1288
|
+
const SNAP_DISTANCE = 48
|
|
1289
|
+
// A drag faster than this, in pixels per millisecond, snaps in its direction even when it is short
|
|
1290
|
+
const SNAP_VELOCITY = 0.5
|
|
1291
|
+
// How far the sheet follows the finger towards the position it is already in
|
|
1292
|
+
const OVERDRAG = 0.25
|
|
1293
|
+
|
|
1294
|
+
interface BottomSheetProps {
|
|
1295
|
+
readonly open: boolean
|
|
1296
|
+
readonly onOpenChange: (open: boolean) => void
|
|
1297
|
+
readonly label: string
|
|
1298
|
+
readonly children: ReactNode
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
interface Drag {
|
|
1302
|
+
readonly startY: number
|
|
1303
|
+
readonly startTime: number
|
|
1304
|
+
delta: number
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// A persistent sheet at the bottom of the layout with two positions, open and collapsed. It is not a modal: the scene
|
|
1308
|
+
// above it stays interactive. Its height comes from its content, so the layout's safe area ends where the sheet
|
|
1309
|
+
// starts and changes only when the sheet snaps. While the handle is dragged the sheet follows the finger with a
|
|
1310
|
+
// transform, which the safe area does not see, so the camera refits once per snap and not on every move.
|
|
1311
|
+
export const BottomSheet = ({ open, onOpenChange, label, children }: BottomSheetProps) => {
|
|
1312
|
+
const sheetRef = useRef<HTMLElement>(null)
|
|
1313
|
+
const dragRef = useRef<Drag | null>(null)
|
|
1314
|
+
const draggedRef = useRef(false)
|
|
1315
|
+
|
|
1316
|
+
const moveSheet = (offset: number, animate: boolean) => {
|
|
1317
|
+
if (sheetRef.current) {
|
|
1318
|
+
sheetRef.current.style.transition = animate ? 'transform 200ms ease-out' : 'none'
|
|
1319
|
+
sheetRef.current.style.transform = offset === 0 ? '' : `translateY(${offset}px)`
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
|
1324
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
1325
|
+
dragRef.current = { startY: event.clientY, startTime: event.timeStamp, delta: 0 }
|
|
1326
|
+
draggedRef.current = false
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
|
1330
|
+
const drag = dragRef.current
|
|
1331
|
+
|
|
1332
|
+
if (drag === null) {
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
drag.delta = event.clientY - drag.startY
|
|
1337
|
+
// Down closes an open sheet and up opens a collapsed one; the other direction only gives a little
|
|
1338
|
+
const towardsOther = open ? drag.delta > 0 : drag.delta < 0
|
|
1339
|
+
moveSheet(towardsOther ? drag.delta : drag.delta * OVERDRAG, false)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const handlePointerEnd = (event: PointerEvent<HTMLButtonElement>) => {
|
|
1343
|
+
const drag = dragRef.current
|
|
1344
|
+
dragRef.current = null
|
|
1345
|
+
|
|
1346
|
+
if (drag === null) {
|
|
1347
|
+
return
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
moveSheet(0, true)
|
|
1351
|
+
|
|
1352
|
+
if (Math.abs(drag.delta) < TAP_DISTANCE) {
|
|
1353
|
+
return
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
draggedRef.current = true
|
|
1357
|
+
const velocity = drag.delta / Math.max(event.timeStamp - drag.startTime, 1)
|
|
1358
|
+
const flicked = Math.abs(drag.delta) >= SNAP_DISTANCE || Math.abs(velocity) >= SNAP_VELOCITY
|
|
1359
|
+
|
|
1360
|
+
if (flicked && (open ? drag.delta > 0 : drag.delta < 0)) {
|
|
1361
|
+
onOpenChange(!open)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// A tap, Enter or Space toggles the sheet. The pointer click that ends a drag has already been handled; a keyboard
|
|
1366
|
+
// click (`detail` 0) always toggles, also after a touch drag that was followed by no click.
|
|
1367
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
1368
|
+
const dragged = draggedRef.current
|
|
1369
|
+
draggedRef.current = false
|
|
1370
|
+
|
|
1371
|
+
if (dragged && event.detail !== 0) {
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
onOpenChange(!open)
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return (
|
|
1379
|
+
<Box
|
|
1380
|
+
component="section"
|
|
1381
|
+
ref={sheetRef}
|
|
1382
|
+
aria-label={label}
|
|
1383
|
+
sx={{
|
|
1384
|
+
position: 'relative',
|
|
1385
|
+
flexShrink: 0,
|
|
1386
|
+
display: 'flex',
|
|
1387
|
+
flexDirection: 'column',
|
|
1388
|
+
maxBlockSize: '55%',
|
|
1389
|
+
paddingBlockEnd: 'env(safe-area-inset-bottom)',
|
|
1390
|
+
borderStartStartRadius: 20,
|
|
1391
|
+
borderStartEndRadius: 20,
|
|
1392
|
+
bgcolor: 'background.paper',
|
|
1393
|
+
boxShadow: '0 -8px 32px rgba(0, 0, 0, 0.12)',
|
|
1394
|
+
// Fills the gap below the sheet while it is dragged up
|
|
1395
|
+
'&::after': {
|
|
1396
|
+
content: '""',
|
|
1397
|
+
position: 'absolute',
|
|
1398
|
+
insetInline: 0,
|
|
1399
|
+
insetBlockStart: '100%',
|
|
1400
|
+
blockSize: '100vh',
|
|
1401
|
+
bgcolor: 'background.paper',
|
|
1402
|
+
},
|
|
1403
|
+
}}
|
|
1404
|
+
>
|
|
1405
|
+
<ButtonBase
|
|
1406
|
+
aria-expanded={open}
|
|
1407
|
+
disableRipple
|
|
1408
|
+
aria-label={open ? `Collapse ${label.toLowerCase()}` : `Expand ${label.toLowerCase()}`}
|
|
1409
|
+
onClick={handleClick}
|
|
1410
|
+
onPointerDown={handlePointerDown}
|
|
1411
|
+
onPointerMove={handlePointerMove}
|
|
1412
|
+
onPointerUp={handlePointerEnd}
|
|
1413
|
+
onPointerCancel={handlePointerEnd}
|
|
1414
|
+
sx={{
|
|
1415
|
+
flexShrink: 0,
|
|
1416
|
+
blockSize: 36,
|
|
1417
|
+
touchAction: 'none',
|
|
1418
|
+
'&.Mui-focusVisible': { outline: 2, outlineColor: 'primary.main', outlineOffset: -4, borderRadius: 3 },
|
|
1419
|
+
}}
|
|
1420
|
+
>
|
|
1421
|
+
<Box component="span" sx={{ inlineSize: 40, blockSize: 4, borderRadius: 2, bgcolor: 'text.disabled' }} />
|
|
1422
|
+
</ButtonBase>
|
|
1423
|
+
{/* A column, so the panel's cards region shrinks first and the price and action stay in view; below the cards
|
|
1424
|
+
region's minimum height the content scrolls as a whole, as the review step does on a short phone */}
|
|
1425
|
+
<Box
|
|
1426
|
+
component="div"
|
|
1427
|
+
sx={{
|
|
1428
|
+
display: 'flex',
|
|
1429
|
+
flexDirection: 'column',
|
|
1430
|
+
flex: '1 1 auto',
|
|
1431
|
+
minBlockSize: 0,
|
|
1432
|
+
overflowY: 'auto',
|
|
1433
|
+
overscrollBehavior: 'contain',
|
|
1434
|
+
}}
|
|
1435
|
+
>
|
|
1436
|
+
{children}
|
|
1437
|
+
</Box>
|
|
1438
|
+
</Box>
|
|
1439
|
+
)
|
|
1440
|
+
}
|
|
1441
|
+
```
|
|
1442
|
+
|
|
855
1443
|
### ./src/\_shared/EdgeFinishCards.tsx
|
|
856
1444
|
|
|
857
1445
|
```tsx
|
|
@@ -990,39 +1578,50 @@ export const EdgeFinishCards = ({ selection }: EdgeFinishCardsProps) => {
|
|
|
990
1578
|
### ./src/\_shared/FullscreenToggle.tsx
|
|
991
1579
|
|
|
992
1580
|
```tsx
|
|
993
|
-
import { Button } from '@mui/material'
|
|
1581
|
+
import { Button, Tooltip } from '@mui/material'
|
|
994
1582
|
|
|
995
1583
|
import { phx } from '../../.phoenix/phx.js'
|
|
996
1584
|
|
|
997
1585
|
const ENTER_ICON = 'M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'
|
|
998
1586
|
const EXIT_ICON = 'M9 4v5H4M15 4v5h5M15 20v-5h5M9 20v-5H4'
|
|
999
1587
|
|
|
1000
|
-
|
|
1588
|
+
interface FullscreenToggleProps {
|
|
1589
|
+
// Only the icon, with the label as its accessible name and tooltip, where a label would cover the product
|
|
1590
|
+
readonly compact?: boolean
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
export const FullscreenToggle = ({ compact = false }: FullscreenToggleProps) => {
|
|
1001
1594
|
const isFullscreen = phx.frontend.useIsFullscreenActive()
|
|
1002
1595
|
const setFullscreen = phx.frontend.useSetFullscreen()
|
|
1596
|
+
const label = isFullscreen ? 'Exit fullscreen' : 'Fullscreen'
|
|
1597
|
+
const icon = (
|
|
1598
|
+
<svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={2}>
|
|
1599
|
+
<path d={isFullscreen ? EXIT_ICON : ENTER_ICON} />
|
|
1600
|
+
</svg>
|
|
1601
|
+
)
|
|
1003
1602
|
|
|
1004
1603
|
return (
|
|
1005
|
-
<
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
</
|
|
1604
|
+
<Tooltip title={compact ? label : ''}>
|
|
1605
|
+
<Button
|
|
1606
|
+
onClick={() => setFullscreen(!isFullscreen)}
|
|
1607
|
+
aria-label={compact ? label : undefined}
|
|
1608
|
+
startIcon={compact ? undefined : icon}
|
|
1609
|
+
sx={{
|
|
1610
|
+
minBlockSize: 44,
|
|
1611
|
+
minInlineSize: 44,
|
|
1612
|
+
paddingInline: compact ? 0 : 2.5,
|
|
1613
|
+
borderRadius: 999,
|
|
1614
|
+
bgcolor: 'background.paper',
|
|
1615
|
+
color: 'text.primary',
|
|
1616
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
|
1617
|
+
textTransform: 'none',
|
|
1618
|
+
fontWeight: 600,
|
|
1619
|
+
'&:hover': { bgcolor: 'background.paper', boxShadow: '0 6px 20px rgba(0, 0, 0, 0.18)' },
|
|
1620
|
+
}}
|
|
1621
|
+
>
|
|
1622
|
+
{compact ? icon : label}
|
|
1623
|
+
</Button>
|
|
1624
|
+
</Tooltip>
|
|
1026
1625
|
)
|
|
1027
1626
|
}
|
|
1028
1627
|
```
|
|
@@ -1097,7 +1696,14 @@ import { ReviewActions } from './ReviewActions.js'
|
|
|
1097
1696
|
|
|
1098
1697
|
const BUTTON_SX = { minBlockSize: 48, borderRadius: 999, textTransform: 'none', fontWeight: 600 } as const
|
|
1099
1698
|
|
|
1100
|
-
|
|
1699
|
+
interface ProductPanelProps {
|
|
1700
|
+
// Only the name, the price and the action, for a collapsed bottom sheet
|
|
1701
|
+
readonly collapsed?: boolean
|
|
1702
|
+
// In a bottom sheet the cards region keeps the top of the cards in view, and the sheet scrolls when that does not fit
|
|
1703
|
+
readonly inSheet?: boolean
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export const ProductPanel = ({ collapsed = false, inSheet = false }: ProductPanelProps) => {
|
|
1101
1707
|
const resolve = useResolveLocalizable()
|
|
1102
1708
|
const detail = phx.frontend.useFirstRootComponent(detailComponent)
|
|
1103
1709
|
const price = phx.frontend.useRootEntityPrice()
|
|
@@ -1134,29 +1740,44 @@ export const ProductPanel = () => {
|
|
|
1134
1740
|
<Typography variant="h5" component="h2" fontWeight={700}>
|
|
1135
1741
|
{resolve(detail?.state.title)}
|
|
1136
1742
|
</Typography>
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
<Box component="div" sx={{ display: 'grid', gap: 1.5, paddingInline: 3, paddingBlockEnd: 3, overflowY: 'auto' }}>
|
|
1143
|
-
<Typography variant="subtitle2" component="h3">
|
|
1144
|
-
Edge finish
|
|
1145
|
-
</Typography>
|
|
1146
|
-
{isReview ? (
|
|
1147
|
-
<Box component="div" sx={{ padding: 2, borderRadius: 3, bgcolor: 'action.hover' }}>
|
|
1148
|
-
<Typography variant="body2" fontWeight={600}>
|
|
1149
|
-
{resolve(chosenEdge?.title)}
|
|
1150
|
-
</Typography>
|
|
1151
|
-
<Typography variant="caption" color="text.secondary">
|
|
1152
|
-
{resolve(chosenEdge?.description)}
|
|
1153
|
-
</Typography>
|
|
1154
|
-
</Box>
|
|
1155
|
-
) : (
|
|
1156
|
-
<EdgeFinishCards selection={edgeFinish} />
|
|
1743
|
+
{!collapsed && (
|
|
1744
|
+
<Typography variant="body2" color="text.secondary">
|
|
1745
|
+
{resolve(detail?.state.description)}
|
|
1746
|
+
</Typography>
|
|
1157
1747
|
)}
|
|
1158
1748
|
</Box>
|
|
1159
1749
|
|
|
1750
|
+
{!collapsed && (
|
|
1751
|
+
<Box
|
|
1752
|
+
component="div"
|
|
1753
|
+
sx={{
|
|
1754
|
+
display: 'grid',
|
|
1755
|
+
gap: 1.5,
|
|
1756
|
+
paddingInline: 3,
|
|
1757
|
+
paddingBlockEnd: 3,
|
|
1758
|
+
// In a short container the region shrinks and scrolls; a scroll past its end moves its container on
|
|
1759
|
+
minBlockSize: inSheet ? 120 : 0,
|
|
1760
|
+
overflowY: 'auto',
|
|
1761
|
+
}}
|
|
1762
|
+
>
|
|
1763
|
+
<Typography variant="subtitle2" component="h3">
|
|
1764
|
+
Edge finish
|
|
1765
|
+
</Typography>
|
|
1766
|
+
{isReview ? (
|
|
1767
|
+
<Box component="div" sx={{ padding: 2, borderRadius: 3, bgcolor: 'action.hover' }}>
|
|
1768
|
+
<Typography variant="body2" fontWeight={600}>
|
|
1769
|
+
{resolve(chosenEdge?.title)}
|
|
1770
|
+
</Typography>
|
|
1771
|
+
<Typography variant="caption" color="text.secondary">
|
|
1772
|
+
{resolve(chosenEdge?.description)}
|
|
1773
|
+
</Typography>
|
|
1774
|
+
</Box>
|
|
1775
|
+
) : (
|
|
1776
|
+
<EdgeFinishCards selection={edgeFinish} />
|
|
1777
|
+
)}
|
|
1778
|
+
</Box>
|
|
1779
|
+
)}
|
|
1780
|
+
|
|
1160
1781
|
<Box component="footer" sx={{ display: 'grid', gap: 2, padding: 3, borderTop: 1, borderColor: 'divider' }}>
|
|
1161
1782
|
<Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
|
1162
1783
|
<Typography variant="body2" color="text.secondary">
|
|
@@ -1224,7 +1845,9 @@ interface ReviewActionsProps {
|
|
|
1224
1845
|
|
|
1225
1846
|
// The review step's buttons: one finalize action and the way back. `useFinalizeAction` runs the action's handler,
|
|
1226
1847
|
// so this component calls it exactly once and is rendered inside a `PhoenixHandlerBoundary` for that handler.
|
|
1227
|
-
// Back is disabled while the request is pending, so the
|
|
1848
|
+
// Back is disabled while the request is pending, so the customer cannot change the configuration while it is sent.
|
|
1849
|
+
// The pending state belongs to this component: when the panel unmounts, as in fullscreen, the request goes on and
|
|
1850
|
+
// the remounted buttons are enabled again.
|
|
1228
1851
|
export const ReviewActions = ({ handler, label, onBack }: ReviewActionsProps) => {
|
|
1229
1852
|
const { run, isPending, error, disabled, hints } = phx.frontend.useFinalizeAction(handler)
|
|
1230
1853
|
|
|
@@ -1260,26 +1883,43 @@ export const ReviewActions = ({ handler, label, onBack }: ReviewActionsProps) =>
|
|
|
1260
1883
|
|
|
1261
1884
|
```tsx
|
|
1262
1885
|
import { Box } from '@mui/material'
|
|
1886
|
+
import { useState } from 'react'
|
|
1263
1887
|
|
|
1264
1888
|
import { LayoutSlots, useSceneSafeArea } from '@salsita-ai/sdk/configurator'
|
|
1889
|
+
import { useActiveViewports } from '@salsita-ai/sdk/react'
|
|
1265
1890
|
|
|
1266
1891
|
import { phx } from '../../.phoenix/phx.js'
|
|
1892
|
+
import { BottomSheet } from '../_shared/BottomSheet.js'
|
|
1267
1893
|
import { FullscreenToggle } from '../_shared/FullscreenToggle.js'
|
|
1268
1894
|
import { MessageToast } from '../_shared/MessageToast.js'
|
|
1269
1895
|
import { ProductPanel } from '../_shared/ProductPanel.js'
|
|
1270
1896
|
|
|
1271
1897
|
const GUTTER = 24
|
|
1272
1898
|
const PANEL_WIDTH = 380
|
|
1899
|
+
const PHONE_GUTTER = 16
|
|
1900
|
+
const TOUCH_TARGET = 44
|
|
1273
1901
|
|
|
1274
1902
|
const ConfiguratorLayout = () => {
|
|
1275
1903
|
const { containerRef, safeAreaRef } = useSceneSafeArea()
|
|
1276
1904
|
const isFullscreen = phx.frontend.useIsFullscreenActive()
|
|
1905
|
+
// `null` until the container has been measured; the layout shows only the scene until then
|
|
1906
|
+
const viewports = useActiveViewports()
|
|
1907
|
+
const [isSheetOpen, setSheetOpen] = useState(false)
|
|
1908
|
+
|
|
1909
|
+
const isDesktop = viewports?.includes('desktop') ?? false
|
|
1910
|
+
const gutter = isDesktop ? GUTTER : PHONE_GUTTER
|
|
1277
1911
|
|
|
1278
1912
|
return (
|
|
1279
|
-
<Box
|
|
1913
|
+
<Box
|
|
1914
|
+
component="div"
|
|
1915
|
+
ref={containerRef}
|
|
1916
|
+
// `clip`, not `hidden`: what overflows, such as the sheet's fill below its edge, can never be scrolled into view,
|
|
1917
|
+
// not even by the browser when the focus moves
|
|
1918
|
+
sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'clip' }}
|
|
1919
|
+
>
|
|
1280
1920
|
<LayoutSlots.SharedCanvas />
|
|
1281
1921
|
|
|
1282
|
-
{!isFullscreen && (
|
|
1922
|
+
{viewports !== null && isDesktop && !isFullscreen && (
|
|
1283
1923
|
<Box
|
|
1284
1924
|
component="aside"
|
|
1285
1925
|
sx={{
|
|
@@ -1293,37 +1933,60 @@ const ConfiguratorLayout = () => {
|
|
|
1293
1933
|
borderRadius: 5,
|
|
1294
1934
|
bgcolor: 'background.paper',
|
|
1295
1935
|
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.12)',
|
|
1296
|
-
|
|
1936
|
+
// A very short container, such as a phone held sideways, scrolls the panel instead of clipping its footer
|
|
1937
|
+
overflowX: 'hidden',
|
|
1938
|
+
overflowY: 'auto',
|
|
1939
|
+
overscrollBehavior: 'contain',
|
|
1297
1940
|
}}
|
|
1298
1941
|
>
|
|
1299
1942
|
<ProductPanel />
|
|
1300
1943
|
</Box>
|
|
1301
1944
|
)}
|
|
1302
1945
|
|
|
1303
|
-
|
|
1304
|
-
component="div"
|
|
1305
|
-
ref={safeAreaRef}
|
|
1306
|
-
sx={{
|
|
1307
|
-
position: 'absolute',
|
|
1308
|
-
insetBlock: 0,
|
|
1309
|
-
insetInlineStart: isFullscreen ? 0 : PANEL_WIDTH + 2 * GUTTER,
|
|
1310
|
-
insetInlineEnd: 0,
|
|
1311
|
-
pointerEvents: 'none',
|
|
1312
|
-
}}
|
|
1313
|
-
>
|
|
1946
|
+
{viewports !== null && (
|
|
1314
1947
|
<Box
|
|
1315
1948
|
component="div"
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1949
|
+
ref={safeAreaRef}
|
|
1950
|
+
sx={{
|
|
1951
|
+
// Desktop: right of the panel. Phone: above the sheet, whose height the column gives the rest.
|
|
1952
|
+
...(isDesktop
|
|
1953
|
+
? {
|
|
1954
|
+
position: 'absolute',
|
|
1955
|
+
insetBlock: 0,
|
|
1956
|
+
insetInlineStart: isFullscreen ? 0 : PANEL_WIDTH + 2 * GUTTER,
|
|
1957
|
+
insetInlineEnd: 0,
|
|
1958
|
+
}
|
|
1959
|
+
: { position: 'relative', flex: '1 1 0', minBlockSize: 0 }),
|
|
1960
|
+
pointerEvents: 'none',
|
|
1961
|
+
}}
|
|
1323
1962
|
>
|
|
1324
|
-
<
|
|
1963
|
+
<Box
|
|
1964
|
+
component="div"
|
|
1965
|
+
sx={{ position: 'absolute', insetBlockStart: gutter, insetInlineEnd: gutter, pointerEvents: 'auto' }}
|
|
1966
|
+
>
|
|
1967
|
+
<FullscreenToggle compact={!isDesktop} />
|
|
1968
|
+
</Box>
|
|
1969
|
+
<Box
|
|
1970
|
+
component="div"
|
|
1971
|
+
sx={{
|
|
1972
|
+
position: 'absolute',
|
|
1973
|
+
pointerEvents: 'auto',
|
|
1974
|
+
// Desktop: bottom left. Phone: at the top beside the fullscreen button, clear of the sheet's handle.
|
|
1975
|
+
...(isDesktop
|
|
1976
|
+
? { insetBlockEnd: GUTTER, insetInlineStart: GUTTER }
|
|
1977
|
+
: { insetBlockStart: gutter, insetInline: `${gutter}px ${2 * gutter + TOUCH_TARGET}px` }),
|
|
1978
|
+
}}
|
|
1979
|
+
>
|
|
1980
|
+
<MessageToast />
|
|
1981
|
+
</Box>
|
|
1325
1982
|
</Box>
|
|
1326
|
-
|
|
1983
|
+
)}
|
|
1984
|
+
|
|
1985
|
+
{viewports !== null && !isDesktop && !isFullscreen && (
|
|
1986
|
+
<BottomSheet open={isSheetOpen} onOpenChange={setSheetOpen} label="Product details">
|
|
1987
|
+
<ProductPanel collapsed={!isSheetOpen} inSheet />
|
|
1988
|
+
</BottomSheet>
|
|
1989
|
+
)}
|
|
1327
1990
|
</Box>
|
|
1328
1991
|
)
|
|
1329
1992
|
}
|
|
@@ -1331,6 +1994,68 @@ const ConfiguratorLayout = () => {
|
|
|
1331
1994
|
export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
|
|
1332
1995
|
```
|
|
1333
1996
|
|
|
1997
|
+
### ./src/elements/EdgeFinishHotspot.tsx
|
|
1998
|
+
|
|
1999
|
+
```tsx
|
|
2000
|
+
import { Box, ButtonBase, Popover, Tooltip } from '@mui/material'
|
|
2001
|
+
import { useState } from 'react'
|
|
2002
|
+
|
|
2003
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2004
|
+
import { EdgeFinishCards } from '../_shared/EdgeFinishCards.js'
|
|
2005
|
+
|
|
2006
|
+
// The edge profile the button shows, the square edge of the cards' drawings
|
|
2007
|
+
const EDGE_ICON = 'M3 7H17L21 11V13L17 17H3Z'
|
|
2008
|
+
|
|
2009
|
+
// Rendered by the spec's `htmlHotspot` on the table's edge: a button on the product that opens the edge finishes
|
|
2010
|
+
// next to the edge, with the same cards as the panel. The spec's `$filter` decides when it is shown.
|
|
2011
|
+
export default phx.frontend.createCustomPhoenixEntityElement('EdgeFinishHotspot', ({ entityId }) => {
|
|
2012
|
+
const edgeFinish = phx.frontend.useAttributeSelection(entityId, 'edgeFinish')
|
|
2013
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
|
|
2014
|
+
|
|
2015
|
+
return (
|
|
2016
|
+
<>
|
|
2017
|
+
<Tooltip title="Edge finish">
|
|
2018
|
+
<ButtonBase
|
|
2019
|
+
aria-label="Edge finish"
|
|
2020
|
+
aria-haspopup="dialog"
|
|
2021
|
+
aria-expanded={anchor !== null}
|
|
2022
|
+
onClick={event => setAnchor(event.currentTarget)}
|
|
2023
|
+
sx={{
|
|
2024
|
+
display: 'grid',
|
|
2025
|
+
placeItems: 'center',
|
|
2026
|
+
inlineSize: 44,
|
|
2027
|
+
blockSize: 44,
|
|
2028
|
+
borderRadius: '50%',
|
|
2029
|
+
bgcolor: 'background.paper',
|
|
2030
|
+
color: 'primary.main',
|
|
2031
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)',
|
|
2032
|
+
'&.Mui-focusVisible': { outline: 2, outlineColor: 'primary.main', outlineOffset: 2 },
|
|
2033
|
+
}}
|
|
2034
|
+
>
|
|
2035
|
+
<svg viewBox="0 0 24 24" width={22} height={22} aria-hidden>
|
|
2036
|
+
<path d={EDGE_ICON} fill="currentColor" />
|
|
2037
|
+
</svg>
|
|
2038
|
+
</ButtonBase>
|
|
2039
|
+
</Tooltip>
|
|
2040
|
+
<Popover
|
|
2041
|
+
open={anchor !== null}
|
|
2042
|
+
anchorEl={anchor}
|
|
2043
|
+
onClose={() => setAnchor(null)}
|
|
2044
|
+
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
2045
|
+
transformOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
2046
|
+
slotProps={{
|
|
2047
|
+
paper: { role: 'dialog', 'aria-label': 'Edge finish', sx: { borderRadius: 4, marginBlockStart: -1 } },
|
|
2048
|
+
}}
|
|
2049
|
+
>
|
|
2050
|
+
<Box component="div" sx={{ inlineSize: 340, maxInlineSize: 'calc(100vw - 32px)', padding: 2 }}>
|
|
2051
|
+
<EdgeFinishCards selection={edgeFinish} />
|
|
2052
|
+
</Box>
|
|
2053
|
+
</Popover>
|
|
2054
|
+
</>
|
|
2055
|
+
)
|
|
2056
|
+
})
|
|
2057
|
+
```
|
|
2058
|
+
|
|
1334
2059
|
### ./src/expressions/table.ts
|
|
1335
2060
|
|
|
1336
2061
|
```ts
|