@mohasinac/appkit 4.53.2 → 4.54.0-next.0
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/.build-stamp.json +5 -5
- package/dist/_internal/server/features/auth/capabilities.js +7 -5
- package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
- package/dist/_internal/server/features/products/listing-tab-counts.js +81 -25
- package/dist/_internal/server/features/search/index-build/sources.js +43 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
- package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +128 -0
- package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
- package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
- package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
- package/dist/_internal/shared/listing-types/tiers.js +150 -0
- package/dist/cli/index.js +0 -2
- package/dist/client.d.ts +21 -2
- package/dist/client.js +23 -4
- package/dist/constants/api-endpoints.d.ts +21 -0
- package/dist/constants/api-endpoints.js +7 -0
- package/dist/features/account/components/UserOrdersView.js +26 -7
- package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
- package/dist/features/addresses/utils/postal-autofill.js +54 -0
- package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
- package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
- package/dist/features/admin/components/OwnerQuickActions.js +142 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
- package/dist/features/auth/permissions/constants.d.ts +29 -2
- package/dist/features/auth/permissions/constants.js +68 -1
- package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
- package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
- package/dist/features/cart/components/CartDrawer.js +3 -2
- package/dist/features/cart/components/CartGroupLineRow.js +2 -1
- package/dist/features/cart/hooks/useAddToCart.js +10 -5
- package/dist/features/cart/utils/pending-ops.d.ts +11 -1
- package/dist/features/cart/utils/pending-ops.js +14 -2
- package/dist/features/categories/components/BrandDetailPageView.js +2 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +2 -1
- package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
- package/dist/features/categories/components/CategoryGrid.js +9 -2
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
- package/dist/features/events/components/EventCard.d.ts +11 -1
- package/dist/features/events/components/EventCard.js +9 -2
- package/dist/features/layout/AppLayoutShell.js +2 -1
- package/dist/features/layout/BottomActions.d.ts +1 -0
- package/dist/features/layout/BottomActions.js +330 -0
- package/dist/features/layout/BottomChrome.d.ts +45 -0
- package/dist/features/layout/BottomChrome.js +5 -17
- package/dist/features/layout/SideActionDock.js +36 -13
- package/dist/features/layout/index.d.ts +2 -7
- package/dist/features/layout/index.js +7 -7
- package/dist/features/layout/useIsDesktop.d.ts +36 -0
- package/dist/features/layout/useIsDesktop.js +48 -0
- package/dist/features/media/messages/en.json +67 -67
- package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
- package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
- package/dist/features/orders/components/index.d.ts +2 -1
- package/dist/features/orders/components/index.js +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
- package/dist/features/orders/utils/order-announcement-message.js +16 -0
- package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
- package/dist/features/orders/utils/payment-proof-message.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +227 -0
- package/dist/features/parts/schemas/firestore.js +6 -6
- package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
- package/dist/features/products/api/[id]/route.js +8 -1
- package/dist/features/products/api/route.js +16 -4
- package/dist/features/products/components/AvailabilityScopeHint.js +1 -1
- package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
- package/dist/features/products/components/InteractiveProductCard.js +16 -4
- package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
- package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
- package/dist/features/products/components/ProductDetailPageView.js +27 -2
- package/dist/features/products/components/ProductGrid.d.ts +14 -1
- package/dist/features/products/components/ProductGrid.js +22 -5
- package/dist/features/products/components/ProductsIndexListing.js +2 -2
- package/dist/features/reviews/api/[id]/route.js +83 -23
- package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
- package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
- package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
- package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
- package/dist/features/reviews/components/index.d.ts +4 -0
- package/dist/features/reviews/components/index.js +2 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +49 -3
- package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
- package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
- package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
- package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
- package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
- package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
- package/dist/features/stores/components/index.d.ts +0 -2
- package/dist/features/stores/components/index.js +0 -1
- package/dist/features/stores/schemas/index.d.ts +1 -1
- package/dist/features/stores/schemas/index.js +10 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +403 -0
- package/dist/index.d.ts +8 -27
- package/dist/index.js +14 -35
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
- package/dist/providers/shipping-shiprocket/index.js +353 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/security/csp.d.ts +16 -1
- package/dist/security/csp.js +5 -3
- package/dist/seed/_helpers/category-forest.js +27 -0
- package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
- package/dist/seed/_helpers/category-parts.d.ts +1 -1
- package/dist/seed/_helpers/category-parts.js +13412 -3020
- package/dist/seed/categories-seed-data.js +48 -6
- package/dist/seed/homepage-sections-seed-data.js +41 -28
- package/dist/seed/model-composition-seed-data.js +6 -8
- package/dist/seed/part-glossary/chunk0.d.ts +9 -0
- package/dist/seed/part-glossary/chunk0.js +35044 -0
- package/dist/seed/part-glossary/chunk1.d.ts +9 -0
- package/dist/seed/part-glossary/chunk1.js +39955 -0
- package/dist/seed/part-glossary/chunk2.d.ts +9 -0
- package/dist/seed/part-glossary/chunk2.js +50335 -0
- package/dist/seed/part-glossary/chunk3.d.ts +9 -0
- package/dist/seed/part-glossary/chunk3.js +52386 -0
- package/dist/seed/part-glossary/chunk4.d.ts +9 -0
- package/dist/seed/part-glossary/chunk4.js +50549 -0
- package/dist/seed/part-glossary-seed-data.d.ts +1 -1
- package/dist/seed/part-glossary-seed-data.js +23 -47483
- package/dist/server.d.ts +4 -8
- package/dist/server.js +8 -20
- package/dist/styles.css +516 -358
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +3 -1
- package/dist/tokens/themes/index.js +11 -1
- package/dist/tokens/themes/technical-dark.d.ts +30 -0
- package/dist/tokens/themes/technical-dark.js +103 -0
- package/dist/tokens/themes/technical-light.d.ts +22 -0
- package/dist/tokens/themes/technical-light.js +89 -0
- package/dist/tokens/tokens.css +155 -0
- package/dist/ui/components/GatedPrice.js +30 -3
- package/dist/ui/components/GatedPrice.style.css +22 -19
- package/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
- package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
- package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
- package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
- package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
- package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
- package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
- package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
- package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/components/index.js +0 -2
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
- package/dist/features/whatsapp-bot/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/index.js +0 -3
- package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
- package/dist/features/whatsapp-bot/manifest.js +0 -13
- package/dist/features/whatsapp-bot/messages/en.json +0 -7
- package/dist/features/whatsapp-bot/server.d.ts +0 -7
- package/dist/features/whatsapp-bot/server.js +0 -7
- package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
- package/dist/features/whatsapp-bot/types/index.js +0 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* BottomActions — the PHONE half of the page-action control.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 Mobile only, unconditionally. `SideActionDock` is the desktop half, and
|
|
7
|
+
* `useIsDesktop` is the single line that chooses between them; both read the
|
|
8
|
+
* same `BottomActionsContext`, so every publisher (`useBottomActions`,
|
|
9
|
+
* `useFormBottomActions`, every `DataListingView` bulk bar) feeds whichever
|
|
10
|
+
* one is mounted without knowing which that is.
|
|
11
|
+
*
|
|
12
|
+
* Why a bar here and a dock there: a phone has a thumb zone and a bottom edge
|
|
13
|
+
* that already means something, and this bar stacks correctly above the tab
|
|
14
|
+
* bar inside `BottomChrome`. A floating puck on a phone takes content space on
|
|
15
|
+
* the screen with the least of it. At 1440px the argument reverses — see
|
|
16
|
+
* `SideActionDock`.
|
|
17
|
+
*
|
|
18
|
+
* 🛑 THE `desktop` OPTION IS GONE FROM THIS COMPONENT. It used to carry an
|
|
19
|
+
* `"always" | "after-scroll"` opt-in with its own `scrollY > 400` gate. That
|
|
20
|
+
* threshold is a guess about where a page's CTA sits — wrong on a short page
|
|
21
|
+
* and wrong on a long gallery — and the dock now owns desktop outright, so a
|
|
22
|
+
* page opting the BAR onto desktop would put two toolbars on one screen.
|
|
23
|
+
* `BottomActionsState.desktop` still exists for callers; this component no
|
|
24
|
+
* longer reads it.
|
|
25
|
+
*
|
|
26
|
+
* A fixed-bottom mobile action bar rendered **above** BottomNavbar (bottom-14).
|
|
27
|
+
* Reads from `BottomActionsContext` — features register their actions via the
|
|
28
|
+
* `useBottomActions` hook; this component just renders whatever is registered.
|
|
29
|
+
*
|
|
30
|
+
* Two modes:
|
|
31
|
+
* - **Page mode** — shows registered page-level actions inline (Add to Cart,
|
|
32
|
+
* Buy Now, Place Bid, Proceed to Checkout, etc.) with an optional info label.
|
|
33
|
+
* - **Bulk mode** — activates when sieveFilter("bulk.selectedCount", SIEVE_OP.GT, "0"); shows:
|
|
34
|
+
* • Selection count pill on the left (tap to deselect all)
|
|
35
|
+
* • An upward-opening type-picker dropdown (middle, flex-1) — tap to
|
|
36
|
+
* choose WHICH action to run; the chosen label is always visible.
|
|
37
|
+
* • An "Apply" submit button on the right — executes the selected action,
|
|
38
|
+
* styled with the selected action's variant (danger = red, etc.).
|
|
39
|
+
*
|
|
40
|
+
* Layout rules:
|
|
41
|
+
* - Not mounted at all on lg+ screens.
|
|
42
|
+
* - It reveals only once the page's own inline CTA has been SEEN and scrolled
|
|
43
|
+
* past (`useCtaReveal`), and hides again when that CTA comes back into view.
|
|
44
|
+
* A bar duplicating a button the user is already looking at is noise.
|
|
45
|
+
* - The bar collapses rather than translating when hidden, so the height it
|
|
46
|
+
* publishes through `BottomChrome` is honest at every frame.
|
|
47
|
+
*
|
|
48
|
+
* @component
|
|
49
|
+
* @example
|
|
50
|
+
* // Automatically rendered by LayoutClient — no manual usage required.
|
|
51
|
+
* // Features use `useBottomActions` to register their actions.
|
|
52
|
+
*/
|
|
53
|
+
import { useState, useRef, useEffect, useId } from "react";
|
|
54
|
+
import { X, ChevronUp, ChevronDown, Check } from "lucide-react";
|
|
55
|
+
import { useBottomActionsContext } from "./BottomActionsContext.js";
|
|
56
|
+
import { useClickOutside } from "../../react/index.js";
|
|
57
|
+
import { useCtaReveal } from "./useCtaReveal.js";
|
|
58
|
+
import { useIsDesktop } from "./useIsDesktop.js";
|
|
59
|
+
import { ActionRow, Div, Row, Span, Text, Button } from "../../ui/index.js";
|
|
60
|
+
// Token values inlined from @mohasinac/appkit/tokens
|
|
61
|
+
const BOTTOM_NAV_BG = "bg-[color-mix(in_srgb,var(--appkit-color-bg)_90%,transparent)] backdrop-blur-md border-t border-[var(--appkit-color-border)]";
|
|
62
|
+
const BOTTOM_NAV_HEIGHT = "h-14";
|
|
63
|
+
const FLEX_CENTER = "flex items-center justify-center";
|
|
64
|
+
const CLS_COUNT_BADGE = "bg-error-solid text-error-on-solid";
|
|
65
|
+
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
66
|
+
function BulkPickerPanel({ bulkActions, selectedActionId, pickerOpen, onSelect, }) {
|
|
67
|
+
return (_jsx(Div, { role: "listbox", "aria-label": "Bulk actions", className: [
|
|
68
|
+
// Flips with the tier — see InfoPanel for why.
|
|
69
|
+
"absolute left-0 right-0 overflow-hidden",
|
|
70
|
+
"bottom-full lg:bottom-auto lg:top-full",
|
|
71
|
+
BOTTOM_NAV_BG,
|
|
72
|
+
"border-t lg:border-t-0 lg:border-b border-[var(--appkit-color-border)]/80",
|
|
73
|
+
"shadow-[0_-8px_24px_rgba(0,0,0,0.10)] dark:shadow-[0_-8px_24px_rgba(0,0,0,0.35)]",
|
|
74
|
+
"lg:shadow-[0_8px_24px_rgba(0,0,0,0.10)] dark:lg:shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
|
|
75
|
+
"transition-[max-height,opacity] duration-200 ease-out",
|
|
76
|
+
pickerOpen ? "max-h-64 opacity-100" : "max-h-0 opacity-0 pointer-events-none",
|
|
77
|
+
]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(" "), children: bulkActions.map((action, i) => {
|
|
80
|
+
const isSelected = action.id === selectedActionId;
|
|
81
|
+
return (_jsxs(Button, { role: "option", "aria-selected": isSelected, type: "button", variant: "ghost", disabled: action.disabled || action.loading, onClick: () => onSelect(action.id), className: [
|
|
82
|
+
"w-full flex items-center gap-[var(--appkit-space-3)] px-[var(--appkit-space-5)] py-[var(--appkit-space-3-5)] text-left text-[length:var(--appkit-text-sm)] font-medium transition-colors rounded-none",
|
|
83
|
+
i > 0 ? "border-t border-zinc-100/80 border-[var(--appkit-color-border-subtle)]" : "",
|
|
84
|
+
isSelected ? " bg-[var(--appkit-color-surface-input)]" : "",
|
|
85
|
+
action.variant === "danger"
|
|
86
|
+
? "text-error hover:bg-error-surface"
|
|
87
|
+
: "text-[var(--appkit-color-text)] hover:bg-surface-hover",
|
|
88
|
+
action.disabled || action.loading ? "opacity-50 cursor-not-allowed" : "",
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(" "), children: [action.icon && (_jsx(Span, { className: `flex-shrink-0 w-5 h-5 ${FLEX_CENTER}`, "aria-hidden": "true", children: action.icon })), _jsx(Span, { className: "flex-1 truncate", children: action.label }), isSelected && (_jsx(Check, { className: "w-4 h-4 flex-shrink-0 text-primary-600 dark:text-primary-400", "aria-hidden": "true" }))] }, action.id));
|
|
92
|
+
}) }));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Expandable detail that opens UPWARD from the bar.
|
|
96
|
+
*
|
|
97
|
+
* Same recipe as BulkPickerPanel above — absolutely positioned at `bottom-full`
|
|
98
|
+
* so it grows into the space above the bar rather than pushing the page, and
|
|
99
|
+
* animated on max-height/opacity so it can't be tabbed into while closed.
|
|
100
|
+
* Scrolls internally, since a many-store cart's breakdown can outgrow the
|
|
101
|
+
* screen.
|
|
102
|
+
*/
|
|
103
|
+
function InfoPanel({ id, open, children, }) {
|
|
104
|
+
return (_jsx(Div, { id: id, className: [
|
|
105
|
+
// Opens UPWARD from a bottom-edge bar, DOWNWARD from a desktop bar
|
|
106
|
+
// hanging under the header — a panel must always expand into the
|
|
107
|
+
// screen, never off it. Border and shadow flip with it, or the sheet
|
|
108
|
+
// is lit from the wrong side and reads as detached from its bar.
|
|
109
|
+
"absolute left-0 right-0 overflow-hidden",
|
|
110
|
+
"bottom-full lg:bottom-auto lg:top-full",
|
|
111
|
+
BOTTOM_NAV_BG,
|
|
112
|
+
"border-t lg:border-t-0 lg:border-b border-[var(--appkit-color-border)]/80",
|
|
113
|
+
"shadow-[0_-8px_24px_rgba(0,0,0,0.10)] dark:shadow-[0_-8px_24px_rgba(0,0,0,0.35)]",
|
|
114
|
+
"lg:shadow-[0_8px_24px_rgba(0,0,0,0.10)] dark:lg:shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
|
|
115
|
+
"transition-[max-height,opacity] duration-200 ease-out",
|
|
116
|
+
open ? "max-h-[60vh] opacity-100" : "max-h-0 opacity-0 pointer-events-none",
|
|
117
|
+
]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(" "), "aria-hidden": !open, children: _jsx(Div, { className: "max-h-[60vh]", overflow: "y-auto", padding: "md", children: children }) }));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* How many actions fit on one line before the row splits in two.
|
|
123
|
+
*
|
|
124
|
+
* 🛑 ROW COUNT is a function of `pageActions.length` and NOTHING else — never
|
|
125
|
+
* of validation state, and never of measured width. `BottomChrome`'s
|
|
126
|
+
* ResizeObserver faithfully republishes `--bottom-chrome-height` on any height
|
|
127
|
+
* change, and every consumer (BackToTop, the pagination bar, the footer's
|
|
128
|
+
* clearance) reflows with it. A bar that grew a row when an error appeared
|
|
129
|
+
* would shift the whole page while the user was typing. The error count lives
|
|
130
|
+
* in `infoLabel`, which sits in the absolutely-positioned panel and is excluded
|
|
131
|
+
* from the published height by construction.
|
|
132
|
+
*
|
|
133
|
+
* The invariant is about ROW COUNT, not pixel height. `ActionRow` lets a long
|
|
134
|
+
* label wrap to a second line, which changes the bar's height by ~16px,
|
|
135
|
+
* monotonically, and the observer handles that correctly. What must never
|
|
136
|
+
* happen is a row count that FLIPS: that is a ~48px step and it is reversible
|
|
137
|
+
* mid-interaction.
|
|
138
|
+
*
|
|
139
|
+
* Which is exactly why the count split stays primary and `flex-wrap` is only an
|
|
140
|
+
* overflow net. In this app LABELS ARE STATE — "Add to Cart" becomes "Adding…",
|
|
141
|
+
* "Buy Now" becomes "Out of Stock", "Apply" becomes "Apply (3)". If width alone
|
|
142
|
+
* decided rows, tapping Add to Cart would shorten the label, free ~40px,
|
|
143
|
+
* un-wrap a row and shift the page under the user's thumb at the exact moment
|
|
144
|
+
* they committed to the tap.
|
|
145
|
+
*
|
|
146
|
+
* 🛑 This constant is COUPLED to `--appkit-size-action-min` (tokens.css).
|
|
147
|
+
* Two actions per line is safe only while `2 × action-min + gap` fits the
|
|
148
|
+
* narrowest supported viewport (280px). Raising either without re-checking that
|
|
149
|
+
* sum reintroduces device-dependent row flipping.
|
|
150
|
+
*/
|
|
151
|
+
const SINGLE_ROW_MAX_ACTIONS = 2;
|
|
152
|
+
function ActionButtons({ actions, dispatchAction, }) {
|
|
153
|
+
return (_jsx(_Fragment, { children: actions.map((action) => {
|
|
154
|
+
const isIconOnly = !action.label;
|
|
155
|
+
/*
|
|
156
|
+
* Width is ActionRow's job now, not this component's — every action is
|
|
157
|
+
* sized from its own label by `.appkit-action-row__group > *`, and only
|
|
158
|
+
* the leftover is shared out. There is deliberately NO per-action width
|
|
159
|
+
* class here: the old `grow` ternary is what produced a 44px stub
|
|
160
|
+
* reading "Ba" next to a full-width "Continue to payment".
|
|
161
|
+
*
|
|
162
|
+
* The one thing a stylesheet cannot infer is that a child has no label,
|
|
163
|
+
* so icon-only opts into its square tap target explicitly.
|
|
164
|
+
*
|
|
165
|
+
* 🛑 Never add `flex-1`, `flex-shrink-0`, `basis-*`, `w-*`, `h-*`,
|
|
166
|
+
* `truncate` or `leading-none` below. Both tailwind configs set
|
|
167
|
+
* `important: true`, so any of them beats ActionRow's CSS and silently
|
|
168
|
+
* reverts this bar to the squeezed behaviour with no visible error.
|
|
169
|
+
*/
|
|
170
|
+
return (_jsxs(Button, { type: "button", variant: action.variant ?? "primary", size: "sm", isLoading: action.loading, disabled: action.disabled, onClick: () => dispatchAction(action.id), className: [
|
|
171
|
+
"relative",
|
|
172
|
+
isIconOnly ? "appkit-action-row__icon-only justify-center" : "",
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(" "), children: [action.icon && (_jsx(Span, { className: ["flex-shrink-0", action.label ? "mr-1.5" : ""].join(" "), "aria-hidden": "true", children: action.icon })), action.label && _jsx(Span, { children: action.label }), action.badge !== undefined && (_jsx(Span, { className: `absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] ${CLS_COUNT_BADGE} text-[10px] ${FLEX_CENTER} px-[var(--appkit-space-1)] pointer-events-none select-none`, rounded: "full", weight: "bold", "aria-hidden": "true", children: action.badge }))] }, action.id));
|
|
176
|
+
}) }));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Lays a form's or a page's actions out across one or two lines.
|
|
180
|
+
*
|
|
181
|
+
* | n | layout |
|
|
182
|
+
* |------|-----------------------------------------------------------------|
|
|
183
|
+
* | 1 | one row, full-width primary |
|
|
184
|
+
* | 2 | one row, each sized from its own label |
|
|
185
|
+
* | 3+ | two rows — secondaries above, primary full-width below |
|
|
186
|
+
*
|
|
187
|
+
* The LAST action is treated as the primary. That holds for every producer:
|
|
188
|
+
* `useFormBottomActions` appends submit last, and the checkout/cart bars build
|
|
189
|
+
* `[back, primary]`. Putting it on its own line keeps the thumb target the same
|
|
190
|
+
* size no matter how many secondary actions a screen has, which is the whole
|
|
191
|
+
* reason a 5-action form (3 forms + soft-ban + hard-ban on the admin user
|
|
192
|
+
* editor) was unrepresentable before.
|
|
193
|
+
*
|
|
194
|
+
* Secondaries WRAP rather than overflowing into a menu. Wrapping keeps the
|
|
195
|
+
* height a pure function of the action count — the invariant above — whereas a
|
|
196
|
+
* popover would add a second interaction to reach an action that is already
|
|
197
|
+
* only one line away. No current caller exceeds five.
|
|
198
|
+
*/
|
|
199
|
+
function PageActionsRow({ pageActions, dispatchAction, }) {
|
|
200
|
+
if (pageActions.length === 0)
|
|
201
|
+
return null;
|
|
202
|
+
if (pageActions.length <= SINGLE_ROW_MAX_ACTIONS) {
|
|
203
|
+
return (_jsx(ActionRow, { children: _jsx(ActionButtons, { actions: pageActions, dispatchAction: dispatchAction }) }));
|
|
204
|
+
}
|
|
205
|
+
const secondaries = pageActions.slice(0, -1);
|
|
206
|
+
const primary = pageActions[pageActions.length - 1];
|
|
207
|
+
return (_jsxs(Div, { className: "flex w-full flex-col gap-[var(--appkit-space-2)]", children: [_jsx(ActionRow, { children: _jsx(ActionButtons, { actions: secondaries, dispatchAction: dispatchAction }) }), _jsx(ActionRow, { children: _jsx(ActionButtons, { actions: [primary], dispatchAction: dispatchAction }) })] }));
|
|
208
|
+
}
|
|
209
|
+
export default function BottomActions() {
|
|
210
|
+
const { state, actionCallbacksRef, bulkCallbacksRef, bulkClearRef } = useBottomActionsContext();
|
|
211
|
+
const isDesktop = useIsDesktop();
|
|
212
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
213
|
+
const [infoOpen, setInfoOpen] = useState(false);
|
|
214
|
+
const [selectedActionId, setSelectedActionId] = useState(null);
|
|
215
|
+
const containerRef = useRef(null);
|
|
216
|
+
const infoPanelId = useId();
|
|
217
|
+
useClickOutside(containerRef, () => {
|
|
218
|
+
setPickerOpen(false);
|
|
219
|
+
setInfoOpen(false);
|
|
220
|
+
});
|
|
221
|
+
const { actions, bulk, infoLabel, secondaryLabel, infoPanel, infoOpenSignal, ctaAnchorId } = state;
|
|
222
|
+
const isBulkMode = !!(bulk && bulk.selectedCount > 0);
|
|
223
|
+
const hasInfoPanel = infoPanel != null && !isBulkMode;
|
|
224
|
+
const bulkActions = bulk?.actions ?? [];
|
|
225
|
+
const pageActions = actions;
|
|
226
|
+
const hasContent = (isBulkMode ? bulkActions.length > 0 || !!bulk : pageActions.length > 0) ||
|
|
227
|
+
!!infoLabel ||
|
|
228
|
+
!!secondaryLabel;
|
|
229
|
+
/*
|
|
230
|
+
* The same reveal rule the dock uses, for the same reason: while the page's
|
|
231
|
+
* own Add-to-Cart is on screen, a second copy of it welded to the bottom
|
|
232
|
+
* edge is a duplicate covering the last row of the page. A selection is its
|
|
233
|
+
* own reveal — bulk mode is a state the user just entered deliberately, and
|
|
234
|
+
* making them scroll to act on it would be a puzzle.
|
|
235
|
+
*/
|
|
236
|
+
const revealed = useCtaReveal(hasContent && !isBulkMode, ctaAnchorId) || isBulkMode;
|
|
237
|
+
const isVisible = hasContent && revealed;
|
|
238
|
+
// A panel left open after its content is withdrawn (bulk mode taking over, or
|
|
239
|
+
// the page clearing it) would sit there as an empty sheet.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!hasInfoPanel)
|
|
242
|
+
setInfoOpen(false);
|
|
243
|
+
}, [hasInfoPanel]);
|
|
244
|
+
/*
|
|
245
|
+
* Force-open on a bumped signal — a failed form submit, today. Depends on
|
|
246
|
+
* the counter alone, so a re-render with the same value never re-opens what
|
|
247
|
+
* the user has since collapsed. `hasInfoPanel` is read but deliberately NOT
|
|
248
|
+
* a dependency: publishing the panel and bumping the signal are two separate
|
|
249
|
+
* setState calls, so re-running on `hasInfoPanel` would open the panel again
|
|
250
|
+
* one render after the user closed it.
|
|
251
|
+
*/
|
|
252
|
+
const lastSignalRef = useRef(undefined);
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (infoOpenSignal == null)
|
|
255
|
+
return;
|
|
256
|
+
if (lastSignalRef.current === infoOpenSignal)
|
|
257
|
+
return;
|
|
258
|
+
lastSignalRef.current = infoOpenSignal;
|
|
259
|
+
setInfoOpen(true);
|
|
260
|
+
}, [infoOpenSignal]);
|
|
261
|
+
// Keep selectedActionId in sync with available bulk actions
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!isBulkMode) {
|
|
264
|
+
setPickerOpen(false);
|
|
265
|
+
setSelectedActionId(null);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
setSelectedActionId((prev) => {
|
|
269
|
+
const ids = bulkActions.map((a) => a.id);
|
|
270
|
+
if (prev && ids.includes(prev))
|
|
271
|
+
return prev;
|
|
272
|
+
return ids[0] ?? null;
|
|
273
|
+
});
|
|
274
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
275
|
+
}, [isBulkMode, bulkActions.map((a) => a.id).join(",")]);
|
|
276
|
+
const selectedAction = bulkActions.find((a) => a.id === selectedActionId);
|
|
277
|
+
const dispatchAction = (id) => actionCallbacksRef.current.get(id)?.();
|
|
278
|
+
const dispatchBulkClear = () => {
|
|
279
|
+
bulkClearRef.current?.();
|
|
280
|
+
setPickerOpen(false);
|
|
281
|
+
};
|
|
282
|
+
const handleApply = () => {
|
|
283
|
+
if (!selectedActionId)
|
|
284
|
+
return;
|
|
285
|
+
bulkCallbacksRef.current.get(selectedActionId)?.();
|
|
286
|
+
setPickerOpen(false);
|
|
287
|
+
};
|
|
288
|
+
// 🛑 AFTER every hook, never before — this flips on resize, and an early
|
|
289
|
+
// return above a `useState` would change the hook order mid-session.
|
|
290
|
+
// `return null`, not `lg:hidden`: see `useIsDesktop` for why the class form
|
|
291
|
+
// cannot be trusted here, and because a display:none bar still mounts,
|
|
292
|
+
// still observes, and can still take focus.
|
|
293
|
+
if (isDesktop)
|
|
294
|
+
return null;
|
|
295
|
+
return (_jsxs(Div, { ref: containerRef, role: "toolbar", "aria-label": isBulkMode ? "Bulk actions" : "Page actions", "aria-hidden": !isVisible, className: [
|
|
296
|
+
// Position, z-index and the offset above the nav all belong to
|
|
297
|
+
// <BottomChrome>, the tier container this renders inside. `relative` is
|
|
298
|
+
// kept because the two panels below are `absolute bottom-full` against it.
|
|
299
|
+
"relative pointer-events-auto",
|
|
300
|
+
]
|
|
301
|
+
.filter(Boolean)
|
|
302
|
+
.join(" "), "data-section": "bottomactions-div-401", children: [isBulkMode && (_jsx(BulkPickerPanel, { bulkActions: bulkActions, selectedActionId: selectedActionId, pickerOpen: pickerOpen, onSelect: (id) => { setSelectedActionId(id); setPickerOpen(false); } })), hasInfoPanel && !isBulkMode && (_jsx(InfoPanel, { id: infoPanelId, open: infoOpen, children: infoPanel })), _jsx(Div, { className: `grid transition-[grid-template-rows] duration-300 ease-out ${isVisible ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`, children: _jsx(Div, { overflow: "hidden", className: "min-h-0", children: _jsxs(Div, { className: [
|
|
303
|
+
BOTTOM_NAV_BG,
|
|
304
|
+
// Cast away from the edge the bar is welded to: upward from the
|
|
305
|
+
// bottom edge, downward from under the header.
|
|
306
|
+
"shadow-[0_-4px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_-4px_20px_rgba(0,0,0,0.30)]",
|
|
307
|
+
"lg:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:lg:shadow-[0_4px_20px_rgba(0,0,0,0.30)]",
|
|
308
|
+
// A hairline on the side facing the page, so the bar reads as
|
|
309
|
+
// chrome rather than as a floating slab.
|
|
310
|
+
"lg:border-b lg:border-[var(--appkit-color-border)]/80",
|
|
311
|
+
isVisible ? "" : "pointer-events-none",
|
|
312
|
+
]
|
|
313
|
+
.filter(Boolean)
|
|
314
|
+
.join(" "), children: [isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-0 border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && !hasInfoPanel && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-0 border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), infoLabel && !isBulkMode && hasInfoPanel && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setInfoOpen((o) => !o), "aria-expanded": infoOpen, "aria-controls": infoPanelId, className: "w-full flex items-center justify-between gap-[var(--appkit-space-2)] rounded-none border-b border-[var(--appkit-color-border)]/80 px-[var(--appkit-space-4)] pt-[var(--appkit-space-2)] pb-[var(--appkit-space-1)] min-h-0", children: [_jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }), _jsxs(Span, { className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1)]", children: [_jsx(Text, { as: "span", size: "xs", color: "muted", children: infoOpen ? "Hide" : "Details" }), infoOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" }))] })] })), _jsx(Row, { className: `min-${BOTTOM_NAV_HEIGHT} py-[var(--appkit-space-2)]`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "flex-shrink-0 bg-primary-50 hover:bg-primary-surface active:bg-primary-200 dark:bg-primary-950/30 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
|
|
315
|
+
? `${bulk.selectedCount} ${bulk.noun}`
|
|
316
|
+
: `${bulk.selectedCount} selected` })] }), bulkActions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
|
|
317
|
+
"flex-1 min-w-0 h-10 flex items-center gap-[var(--appkit-space-2)] px-[var(--appkit-space-3)] rounded-lg border text-[length:var(--appkit-text-sm)] font-medium transition-colors",
|
|
318
|
+
" hover:bg-[var(--appkit-color-surface)] active:bg-zinc-200 bg-[var(--appkit-color-surface-input)] dark:hover:bg-slate-700/60",
|
|
319
|
+
"border-[var(--appkit-color-border)]",
|
|
320
|
+
selectedAction?.variant === "danger"
|
|
321
|
+
? "text-error"
|
|
322
|
+
: "text-[var(--appkit-color-text)]",
|
|
323
|
+
]
|
|
324
|
+
.filter(Boolean)
|
|
325
|
+
.join(" "), children: [selectedAction?.icon && (_jsx(Span, { className: `flex-shrink-0 w-4 h-4 ${FLEX_CENTER}`, "aria-hidden": "true", children: selectedAction.icon })), _jsx(Span, { className: "flex-1 truncate leading-none", align: "start", children: selectedAction?.label ?? "Bulk actions" }), pickerOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 flex-shrink-0 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 flex-shrink-0 text-zinc-400", "aria-hidden": "true" }))] })), bulkActions.length > 0 && (_jsx(Button, { type: "button", variant: selectedAction?.variant ?? "primary", size: "sm", isLoading: selectedAction?.loading, disabled: !selectedActionId ||
|
|
326
|
+
selectedAction?.disabled ||
|
|
327
|
+
selectedAction?.loading, onClick: handleApply, className: "h-10 flex-shrink-0", children: _jsx(Span, { className: "leading-none", children: "Apply" }) }))] })) : (
|
|
328
|
+
/* Page mode — action buttons inline ------------------------------ */
|
|
329
|
+
_jsx(PageActionsRow, { pageActions: pageActions, dispatchAction: dispatchAction })) })] }) }) })] }));
|
|
330
|
+
}
|
|
@@ -2,4 +2,49 @@ import React from "react";
|
|
|
2
2
|
export interface BottomChromeProps {
|
|
3
3
|
children: React.ReactNode;
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* BottomChrome — the middle tier of the bottom edge.
|
|
7
|
+
*
|
|
8
|
+
* The bottom edge is three tiers: the nav bar is the floor, `BackToTop` is the
|
|
9
|
+
* ceiling, and *everything else* lives in here. This container is what makes
|
|
10
|
+
* that middle tier a single thing with a single height:
|
|
11
|
+
*
|
|
12
|
+
* --keyboard-inset-height useVisualViewportInset (the on-screen keyboard)
|
|
13
|
+
* --bottom-nav-height BottomNavLayout (the tab bar)
|
|
14
|
+
* --bottom-chrome-height THIS COMPONENT (CTA row, pagination, …)
|
|
15
|
+
* ↑ BackToTop floats above the sum of all three
|
|
16
|
+
*
|
|
17
|
+
* Because it is a flex column, its own `offsetHeight` IS the sum of whatever is
|
|
18
|
+
* inside it — a bar changing height, revealing on scroll, entering bulk mode,
|
|
19
|
+
* or being added years from now all just resize this box, and the one
|
|
20
|
+
* ResizeObserver below picks every case up. Consumers read one variable and
|
|
21
|
+
* never have to learn about individual bars. Publishing a separate var per bar
|
|
22
|
+
* was the alternative, and it fails the moment someone adds bar number three
|
|
23
|
+
* without also editing every consumer's `calc()`.
|
|
24
|
+
*
|
|
25
|
+
* Two constraints this container places on its children:
|
|
26
|
+
* - A child must hide by COLLAPSING, not by transforming. `translate-y-full`
|
|
27
|
+
* leaves layout height behind, so a hidden bar would still be reserved here
|
|
28
|
+
* on every page that has no CTA. See BottomActions' grid-rows collapse.
|
|
29
|
+
* - No `overflow: hidden` here, ever. BottomActions' bulk-picker and info
|
|
30
|
+
* panels are `absolute bottom-full` on mobile / `top-full` on desktop —
|
|
31
|
+
* deliberately outside the flow (they are transient tap-to-open overlays,
|
|
32
|
+
* not persistent bands, so they are excluded from the published height) —
|
|
33
|
+
* and clipping would erase them.
|
|
34
|
+
*
|
|
35
|
+
* ── The tier CHANGES ENDS at `lg` ──────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Below `lg` it is the bottom edge, as described above. At `lg` and up it hangs
|
|
38
|
+
* from under the sticky header instead, and publishes its height as
|
|
39
|
+
* `--top-chrome-height` rather than `--bottom-chrome-height`.
|
|
40
|
+
*
|
|
41
|
+
* A full-width action bar welded to the bottom of a desktop window is a mobile
|
|
42
|
+
* pattern on a screen that has neither a thumb zone nor a tab bar to stack
|
|
43
|
+
* above — it reads as a browser download bar, and it is the furthest point on
|
|
44
|
+
* screen from the content it acts on. Under the header it sits where the eye
|
|
45
|
+
* already is and where the page's own chrome lives.
|
|
46
|
+
*
|
|
47
|
+
* Consumers therefore read whichever end they care about, and the two are
|
|
48
|
+
* mutually exclusive by construction (see `write()`).
|
|
49
|
+
*/
|
|
5
50
|
export declare function BottomChrome({ children }: BottomChromeProps): React.JSX.Element;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import React, { useEffect, useRef
|
|
3
|
+
import React, { useEffect, useRef } from "react";
|
|
4
4
|
import { Div } from "../../ui/index.js";
|
|
5
5
|
import { BOTTOM_CHROME_SLOT_ID } from "../../ui/components/bottom-chrome-slot.js";
|
|
6
|
+
import { useIsDesktop } from "./useIsDesktop.js";
|
|
6
7
|
/**
|
|
7
8
|
* BottomChrome — the middle tier of the bottom edge.
|
|
8
9
|
*
|
|
@@ -48,26 +49,13 @@ import { BOTTOM_CHROME_SLOT_ID } from "../../ui/components/bottom-chrome-slot.js
|
|
|
48
49
|
* Consumers therefore read whichever end they care about, and the two are
|
|
49
50
|
* mutually exclusive by construction (see `write()`).
|
|
50
51
|
*/
|
|
51
|
-
/**
|
|
52
|
-
* The breakpoint at which the tier moves from the bottom edge to under the
|
|
53
|
-
* header. 64rem is Tailwind's `lg` — the same line `BottomNavbar` disappears
|
|
54
|
-
* at, so the tier changes ends exactly when the thing it was stacking above
|
|
55
|
-
* stops existing.
|
|
56
|
-
*/
|
|
57
|
-
const DESKTOP_QUERY = "(min-width: 64rem)";
|
|
58
52
|
export function BottomChrome({ children }) {
|
|
59
53
|
const ref = useRef(null);
|
|
60
|
-
const [isDesktop, setIsDesktop] = useState(false);
|
|
61
54
|
// Matched in JS as well as in CSS because the MEASUREMENT has to know which
|
|
62
55
|
// variable to publish — a media query alone can move the box but cannot tell
|
|
63
|
-
// the ResizeObserver which end of the screen it ended up at.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const sync = () => setIsDesktop(mq.matches);
|
|
67
|
-
sync();
|
|
68
|
-
mq.addEventListener("change", sync);
|
|
69
|
-
return () => mq.removeEventListener("change", sync);
|
|
70
|
-
}, []);
|
|
56
|
+
// the ResizeObserver which end of the screen it ended up at. Shared with
|
|
57
|
+
// SideActionDock/BottomActions so one line decides the whole layout flip.
|
|
58
|
+
const isDesktop = useIsDesktop();
|
|
71
59
|
useEffect(() => {
|
|
72
60
|
const root = document.documentElement;
|
|
73
61
|
const el = ref.current;
|
|
@@ -7,19 +7,31 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
* (`useBottomActions`, `useFormBottomActions`, every `DataListingView` bulk
|
|
8
8
|
* bar) are untouched: only the presentation changed.
|
|
9
9
|
*
|
|
10
|
-
* ──
|
|
10
|
+
* ── 🛑 DESKTOP ONLY. The phone gets `BottomActions`. ───────────────────────
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* This used to render at every width, and on a phone that was the wrong trade
|
|
13
|
+
* twice over: a floating puck sits in the middle of the content on the screen
|
|
14
|
+
* with the least of it, and it replaced a bar that was already in the thumb
|
|
15
|
+
* zone and already stacked correctly above the tab bar. A phone has a thumb
|
|
16
|
+
* zone and a bottom edge that means something; a 1440px window has neither,
|
|
17
|
+
* which is the entire argument for each half.
|
|
18
|
+
*
|
|
19
|
+
* The two are mutually exclusive by construction — `useIsDesktop` decides, and
|
|
20
|
+
* both halves read the SAME `BottomActionsContext`, so all ~30 publishers are
|
|
21
|
+
* untouched and nothing can register with only one of them.
|
|
16
22
|
*
|
|
17
23
|
* ── Three rules this encodes ───────────────────────────────────────────────
|
|
18
24
|
*
|
|
19
|
-
* 1. **It docks OPPOSITE the sidebar.** `SidebarLayout`
|
|
20
|
-
* side (`hand === "left" ? left-0 : right-0`), so the
|
|
21
|
-
*
|
|
25
|
+
* 1. **It docks OPPOSITE the sidebar, at the vertical MIDDLE.** `SidebarLayout`
|
|
26
|
+
* docks to the hand-mode side (`hand === "left" ? left-0 : right-0`), so the
|
|
27
|
+
* dock takes the other edge — left, in the default right-hand configuration
|
|
28
|
+
* — and the two can never overlap, including while the sidebar is open,
|
|
22
29
|
* which is exactly when a CTA hidden underneath it would be unreachable.
|
|
30
|
+
* Vertically it is centred rather than bottom-anchored: at the bottom of a
|
|
31
|
+
* desktop window it reads as a browser download bar and sits as far from
|
|
32
|
+
* the content as the viewport allows. Centred, it is beside the content,
|
|
33
|
+
* and it needs no bottom-tier clearance at all — there is no tab bar at
|
|
34
|
+
* this width and the pagination bar is at the other end of the screen.
|
|
23
35
|
*
|
|
24
36
|
* 2. **It reveals only after the inline CTA has been seen and passed** — see
|
|
25
37
|
* `useCtaReveal`. A page with no CTA anchor shows it at once, which is what
|
|
@@ -41,6 +53,7 @@ import { useBottomActionsContext } from "./BottomActionsContext.js";
|
|
|
41
53
|
import { useClickOutside } from "../../react/index.js";
|
|
42
54
|
import { useHandMode } from "../../_internal/client/hand-mode/index.js";
|
|
43
55
|
import { useCtaReveal } from "./useCtaReveal.js";
|
|
56
|
+
import { useIsDesktop } from "./useIsDesktop.js";
|
|
44
57
|
import { ActionRow, Div, IconButton, Row, Span, Stack, Text, Button } from "../../ui/index.js";
|
|
45
58
|
const PANEL_SURFACE = "bg-[color-mix(in_srgb,var(--appkit-color-bg)_94%,transparent)] backdrop-blur-md border border-[var(--appkit-color-border)]";
|
|
46
59
|
const FLEX_CENTER = "flex items-center justify-center";
|
|
@@ -111,6 +124,7 @@ function BulkActionList({ bulkActions, selectedActionId, onSelect, }) {
|
|
|
111
124
|
export default function SideActionDock() {
|
|
112
125
|
const { state, actionCallbacksRef, bulkCallbacksRef, bulkClearRef } = useBottomActionsContext();
|
|
113
126
|
const { hand } = useHandMode();
|
|
127
|
+
const isDesktop = useIsDesktop();
|
|
114
128
|
const [expanded, setExpanded] = useState(false);
|
|
115
129
|
const [infoOpen, setInfoOpen] = useState(false);
|
|
116
130
|
const [selectedActionId, setSelectedActionId] = useState(null);
|
|
@@ -130,7 +144,7 @@ export default function SideActionDock() {
|
|
|
130
144
|
!!secondaryLabel;
|
|
131
145
|
// Rule 3 — a selection is its own reveal.
|
|
132
146
|
const revealed = useCtaReveal(hasContent && !isBulkMode, ctaAnchorId) || isBulkMode;
|
|
133
|
-
const isVisible = hasContent && revealed;
|
|
147
|
+
const isVisible = isDesktop && hasContent && revealed;
|
|
134
148
|
// Rule 1 — opposite the sidebar, which docks to the hand-mode side.
|
|
135
149
|
const dockSideClass = hand === "left" ? "right-0" : "left-0";
|
|
136
150
|
const panelAlignClass = hand === "left" ? "items-end" : "items-start";
|
|
@@ -206,10 +220,19 @@ export default function SideActionDock() {
|
|
|
206
220
|
"fixed z-[var(--appkit-z-overlay)] pointer-events-auto flex flex-col",
|
|
207
221
|
panelAlignClass,
|
|
208
222
|
dockSideClass,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
223
|
+
/*
|
|
224
|
+
* Vertically centred — see rule 1. This is a desktop-only control, so
|
|
225
|
+
* it deliberately reads NONE of the three bottom-tier variables: there
|
|
226
|
+
* is no tab bar at this width, the keyboard inset is a phone concern,
|
|
227
|
+
* and the pagination bar is at the opposite end of the viewport. An
|
|
228
|
+
* unused `calc()` over variables that are 0 here is just a claim about
|
|
229
|
+
* a relationship that no longer exists.
|
|
230
|
+
*
|
|
231
|
+
* `max-h` + `justify-center` so a tall expanded panel grows about the
|
|
232
|
+
* midpoint and is clipped by the viewport rather than running off both
|
|
233
|
+
* ends of it; the panel scrolls internally.
|
|
234
|
+
*/
|
|
235
|
+
"top-1/2 -translate-y-1/2 max-h-[90vh] justify-center",
|
|
213
236
|
"px-[var(--appkit-space-3)] gap-[var(--appkit-space-2)]",
|
|
214
237
|
].join(" "), "data-section": "side-action-dock", children: [expanded && (_jsx(Div, { surface: "elevated", rounded: "xl", shadow: "lg", padding: "sm", className: `${PANEL_SURFACE} w-[min(20rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto`, children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", className: "truncate", children: isBulkMode
|
|
215
238
|
? bulk?.noun
|
|
@@ -33,13 +33,8 @@ export type { UseBottomActionsOptions } from "./hooks/useBottomActions";
|
|
|
33
33
|
export { useFormBottomActions } from "./hooks/useFormBottomActions";
|
|
34
34
|
export type { UseFormBottomActionsOptions } from "./hooks/useFormBottomActions";
|
|
35
35
|
export { default as SideActionDock } from "./SideActionDock";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* alias so a consumer that mounts it by name keeps compiling and keeps
|
|
39
|
-
* rendering the same actions — the component it resolves to is
|
|
40
|
-
* `SideActionDock`.
|
|
41
|
-
*/
|
|
42
|
-
export { default as BottomActions } from "./SideActionDock";
|
|
36
|
+
export { default as BottomActions } from "./BottomActions";
|
|
37
|
+
export { useIsDesktop, DESKTOP_QUERY } from "./useIsDesktop";
|
|
43
38
|
export { ctaAnchorProps, CTA_ANCHOR_ATTR } from "./cta-anchor";
|
|
44
39
|
export { BottomChrome } from "./BottomChrome";
|
|
45
40
|
export type { BottomChromeProps } from "./BottomChrome";
|
|
@@ -17,14 +17,14 @@ export { AppLayoutShell } from "./AppLayoutShell.js";
|
|
|
17
17
|
export { BottomActionsProvider, useBottomActionsContext, } from "./BottomActionsContext.js";
|
|
18
18
|
export { useBottomActions } from "./hooks/useBottomActions.js";
|
|
19
19
|
export { useFormBottomActions } from "./hooks/useFormBottomActions.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* rendering the same actions — the component it resolves to is
|
|
25
|
-
* `SideActionDock`.
|
|
20
|
+
/*
|
|
21
|
+
* The two presentations of one control. `AppLayoutShell` mounts both and
|
|
22
|
+
* `useIsDesktop` lets exactly one render — do not mount either directly, or a
|
|
23
|
+
* page gets two toolbars publishing into the same context.
|
|
26
24
|
*/
|
|
27
|
-
export { default as
|
|
25
|
+
export { default as SideActionDock } from "./SideActionDock.js";
|
|
26
|
+
export { default as BottomActions } from "./BottomActions.js";
|
|
27
|
+
export { useIsDesktop, DESKTOP_QUERY } from "./useIsDesktop.js";
|
|
28
28
|
export { ctaAnchorProps, CTA_ANCHOR_ATTR } from "./cta-anchor.js";
|
|
29
29
|
export { BottomChrome } from "./BottomChrome.js";
|
|
30
30
|
export { BottomNavbar } from "./BottomNavbar.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line at which the layout stops being a phone.
|
|
3
|
+
*
|
|
4
|
+
* 64rem is Tailwind's `lg` — deliberately the same breakpoint `BottomNavbar`
|
|
5
|
+
* disappears at, so every decision that depends on "is there a tab bar at the
|
|
6
|
+
* bottom of this screen" flips at one place.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DESKTOP_QUERY = "(min-width: 64rem)";
|
|
9
|
+
/**
|
|
10
|
+
* `true` at `lg` and up.
|
|
11
|
+
*
|
|
12
|
+
* ── 🛑 WHY A HOOK AND NOT A `lg:` CLASS ────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Three callers need the breakpoint as a VALUE, not as styling:
|
|
15
|
+
*
|
|
16
|
+
* - `BottomChrome` must know which CSS variable to publish — a media query
|
|
17
|
+
* can move the box but cannot tell a `ResizeObserver` which end of the
|
|
18
|
+
* screen it landed at.
|
|
19
|
+
* - `SideActionDock` and `BottomActions` are the two halves of one control
|
|
20
|
+
* and must never both be mounted: two toolbars reading the same context,
|
|
21
|
+
* both `fixed`, both dispatching the same callbacks. Hiding one with
|
|
22
|
+
* `display:none` would still mount it, still run its observers, and still
|
|
23
|
+
* let a stray `:focus` land inside an invisible bar.
|
|
24
|
+
*
|
|
25
|
+
* And the class form is actively unsafe here. Both Tailwind configs set
|
|
26
|
+
* `important: true`, so a `lg:` override and its unprefixed base tie on
|
|
27
|
+
* specificity AND on `!important` — a media query adds no specificity — and
|
|
28
|
+
* the winner is decided by EMISSION ORDER in the built CSS, which is invisible
|
|
29
|
+
* from the call site. That is exactly how `BottomChrome` ended up anchored to
|
|
30
|
+
* both edges at once and published an 801px phantom bar (see its header).
|
|
31
|
+
*
|
|
32
|
+
* SSR renders the mobile branch, because that is the branch that is correct
|
|
33
|
+
* when nothing is known: a phone that briefly gets the dock has lost its only
|
|
34
|
+
* CTA, while a desktop that briefly gets the bar has a bar.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useIsDesktop(): boolean;
|