Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,14 @@ onSaveFailed?: (error: unknown) => void
onSaved?: () => void
showAppPublicId?: boolean
store: ConnectionSettingsStore
validateAppPublicId?: (id: string) => string | undefined
```

### CookieConsent

```ts
analytics: boolean
id?: string
marketing: boolean
onConsentChange?: (payload: { type: ConsentType; analytics: boolean; marketing: boolean; }) => void
storageKeys?: CookieConsentStorageKeys
Expand Down Expand Up @@ -957,6 +959,7 @@ _No props beyond HTML attributes and `UIBaseProps`._
```ts
extensionUrl?: string
icon?: string | JSX.Element
id?: string
onDismiss?: () => void
onInstall?: () => void
showDelayMs?: number
Expand Down Expand Up @@ -1507,6 +1510,7 @@ value?: number
```ts
appIcon?: string
appName?: string
id?: string
onDismiss?: () => void
onInstall?: () => void
storageKey?: string
Expand Down
32 changes: 30 additions & 2 deletions docs/ui-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,19 @@ Components require `solid-layouts >=0.2.4` so caller styles reach their root ele
`showDelayMs` when the surrounding onboarding flow needs a different delay;
the same option is available as `ImmersiveLanding.firefoxPWAConfig.showDelayMs`.
The banner still applies its Firefox, standalone-mode, and dismissal checks
before starting that delay.
before starting that delay. Give it an `id` to derive stable IDs for its
banner and actions; `ImmersiveLanding` derives this base from its own `id`
unless `firefoxPWAConfig.id` overrides it.
- Give `PWAInstallPrompt` an `id` to derive stable IDs for its dialog and
actions. `ImmersiveLanding` derives this base from its own `id` unless
`pwaConfig.id` overrides it.
- `ImmersiveLanding.id` is placed on the component root. Its page viewport,
arrows, navigation, optional PWA prompts, and cookie controls remain inside
that ownership subtree and derive their control IDs from the same base.
- Give `CookieConsent` an `id` when its controls need stable authored IDs. Its
banner, dialog, preference inputs, and actions derive unique IDs from that
base. `ImmersiveLanding` forwards `cookieConfig.id` and otherwise derives the
cookie family from the landing's own `id`.
- `Collapsible.Content` retains closed content by default. Set `keepMounted={false}` to mount it only while expanded; the check is reactive, so it mounts and unmounts as the state changes.
- `Popover` accepts `anchorRect` as a rectangle or rectangle accessor when content must be positioned without a trigger element.
- Compound components: `Dialog.Trigger`, `Tabs.List`, `Select.Option`, etc. (`Object.assign` statics; also exported flat: `AccordionRoot`, `AlertTitle`, …). Parts are styleable/testable via `data-slot="..."` and state attrs (`data-open`, `data-selected`, `data-invalid`).
Expand Down Expand Up @@ -730,13 +742,29 @@ export const connection = createConnectionSettings({
});

<ConnectionSettings
id="acme-connection"
store={connection}
endpoints={[{ name: "api", label: "API URL", hint: "leave empty for production" }]}
labels={{ useCustom: "Use a custom backend", save: "Save", reset: "Reset" }}
labels={{
useCustom: "Use a custom backend",
appPublicId: "Application ID",
save: "Save",
reset: "Reset",
}}
showAppPublicId
validateAppPublicId={(id) =>
/^[0-9A-Za-z]{16}$/.test(id)
? undefined
: "Application ID must be 16 letters or numbers"
}
/>
```

When `showAppPublicId`, `labels.appPublicId`, and `validateAppPublicId` are all
present, Save validates the visible application ID before changing persisted
settings or running `onApply`. Return `undefined` to accept it or a message to
show the failure and call `onSaveFailed`.

Read `connection.urls.api` from the transport. It resolves overrides and never
returns an empty string.

Expand Down
16 changes: 16 additions & 0 deletions src/components/connection-settings/ConnectionSettings.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type ConnectionSettingsProps = Omit<
labels: ConnectionSettingsLabels;
/** Omit to hide the app id field entirely. */
showAppPublicId?: boolean;
/** Refuse an application id before it is persisted or applied. */
validateAppPublicId?: (id: string) => string | undefined;
/**
* Rendered inside the revealed region, after the endpoint fields.
*
Expand Down Expand Up @@ -86,6 +88,7 @@ export const ConnectionSettingsLayout: Layout<
"endpoints",
"labels",
"showAppPublicId",
"validateAppPublicId",
"onSaved",
"onSaveFailed",
"onResetDone",
Expand Down Expand Up @@ -196,6 +199,19 @@ export const ConnectionSettingsLayout: Layout<
}
}

if (
props.showAppPublicId &&
props.labels.appPublicId &&
props.validateAppPublicId
) {
const problem = props.validateAppPublicId(appIdValue());
if (problem) {
setFailure(problem);
props.onSaveFailed?.(new Error(problem));
return;
}
}

/*
* Per endpoint, and only for the endpoints this panel shows.
*
Expand Down
17 changes: 15 additions & 2 deletions src/components/immersive-landing/ImmersiveLanding.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =
<div {...{ class: CLASSES.landing.viewport }}>
<div {...{ class: CLASSES.landing.pageLayer }}>{renderChildren()}</div>
</div>
</div>

<Show when={props.overlay || props.appVersion}>
<div {...{ class: CLASSES.landing.overlay }}>
{renderOverlay()}
Expand All @@ -117,6 +115,7 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =
{/* Desktop side arrows */}
{showArrowNav() && (
<ImmersiveLandingArrows
id={props.id ? `${props.id}-desktop` : undefined}
onPrev={navigation.goPrev}
onNext={navigation.goNext}
isFirstPage={navigation.isFirstPage()}
Expand All @@ -127,6 +126,7 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =
{/* Bottom navigation (dots, counter, mobile arrows) */}
{showNav() && (
<ImmersiveLandingNavigation
id={props.id ? `${props.id}-navigation` : undefined}
pages={props.pages}
currentPageIndex={navigation.currentIndex()}
onPageDotClick={navigation.navigateTo}
Expand All @@ -139,6 +139,10 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =

<Show when={props.showPWAPrompt}>
<PWAInstallPrompt
id={
props.pwaConfig?.id ??
(props.id ? `${props.id}-pwa-install` : undefined)
}
appName={props.pwaConfig?.appName}
appIcon={props.pwaConfig?.appIcon}
storageKey={props.pwaConfig?.storageKey ?? "app_pwa_dismissed"}
Expand All @@ -149,6 +153,10 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =
</Show>
<Show when={props.showFirefoxBanner}>
<FirefoxPWABanner
id={
props.firefoxPWAConfig?.id ??
(props.id ? `${props.id}-firefox-pwa` : undefined)
}
extensionUrl={props.firefoxPWAConfig?.extensionUrl}
storageKey={
props.firefoxPWAConfig?.storageKey ?? "app_firefox_pwa_dismissed"
Expand All @@ -161,11 +169,16 @@ const ImmersiveLanding: Layout<typeof componentRecipe, ImmersiveLandingProps> =
</Show>
<Show when={props.showCookieConsent}>
<CookieConsent
id={
props.cookieConfig?.id ??
(props.id ? `${props.id}-cookie-consent` : undefined)
}
storageKeys={props.cookieConfig?.storageKeys}
texts={props.cookieConfig?.texts}
onConsentChange={props.cookieConfig?.onConsentChange}
/>
</Show>
</div>
</ImmersiveLandingContext>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type { Layout } from "../../lib/layouts";
import { componentRecipe } from "./ImmersiveLanding.recipe";

const ImmersiveLandingArrows: Layout<typeof componentRecipe, ImmersiveLandingArrowsProps> = () => {
const others = omit(props, "onPrev", "onNext", "isFirstPage", "isLastPage", "class");
const others = omit(props, "id", "onPrev", "onNext", "isFirstPage", "isLastPage", "class");
const baseId = () => props.id;

const handleNext = () => {
if (props.onNext) {
Expand All @@ -26,6 +27,7 @@ const ImmersiveLandingArrows: Layout<typeof componentRecipe, ImmersiveLandingArr
{/* Left Arrow - Desktop only */}
<Show when={!props.isFirstPage}>
<button
id={baseId() ? `${baseId()}-previous` : undefined}
type="button"
onClick={handlePrev}
{...{
Expand Down Expand Up @@ -53,6 +55,7 @@ const ImmersiveLandingArrows: Layout<typeof componentRecipe, ImmersiveLandingArr
{/* Right Arrow - Desktop only */}
<Show when={!props.isLastPage}>
<button
id={baseId() ? `${baseId()}-next` : undefined}
type="button"
onClick={handleNext}
{...{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { componentRecipe } from "./ImmersiveLanding.recipe";
const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandingNavigationProps> = () => {
const others = omit(
props,
"id",
"pages",
"currentPageIndex",
"onPageDotClick",
Expand All @@ -19,6 +20,7 @@ const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandin
"class",
"style",
);
const baseId = () => props.id;

const mobileArrowClasses = (disabled: boolean) =>
twMerge(
Expand All @@ -28,6 +30,7 @@ const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandin

return (
<nav
id={baseId()}
{...{
class: twMerge(CLASSES.navigation.base, props.class),
}}
Expand All @@ -39,6 +42,7 @@ const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandin
<div {...{ class: CLASSES.navigation.row }}>
{/* Left arrow - Mobile only */}
<button
id={baseId() ? `${baseId()}-previous` : undefined}
type="button"
onClick={props.onPrev}
disabled={props.isFirstPage}
Expand All @@ -62,6 +66,7 @@ const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandin
<For each={props.pages}>
{(pageId, index) => (
<button
id={baseId() ? `${baseId()}-page-${index() + 1}` : undefined}
type="button"
onClick={() => props.onPageDotClick(pageId)}
{...{
Expand All @@ -87,6 +92,7 @@ const ImmersiveLandingNavigation: Layout<typeof componentRecipe, ImmersiveLandin

{/* Right arrow - Mobile only */}
<button
id={baseId() ? `${baseId()}-next` : undefined}
type="button"
onClick={props.onNext}
disabled={props.isLastPage}
Expand Down
23 changes: 19 additions & 4 deletions src/components/immersive-landing/components/CookieConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type Component,
createEffect,
createSignal,
createUniqueId,
onCleanup,
onSettled,
Show,
Expand Down Expand Up @@ -115,6 +116,9 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
// Preference states for manage modal
const [analyticsEnabled, setAnalyticsEnabled] = createSignal(false);
const [marketingEnabled, setMarketingEnabled] = createSignal(false);
const generatedId = createUniqueId();
const baseId = () => props.id?.trim() || `cookie-consent-${generatedId}`;
const controlId = (suffix: string) => `${baseId()}-${suffix}`;

const CONSENT_KEY = () =>
props.storageKeys?.consentKey ?? "app_cookie_consent";
Expand Down Expand Up @@ -239,9 +243,10 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{/* Cookie Consent Banner */}
<Show when={showBanner()}>
<div
id={controlId("banner")}
role="dialog"
aria-modal="false"
aria-labelledby="cookie-consent-message"
aria-labelledby={controlId("message")}
{...{
class: `${CLASSES.cookie.banner}${isClosing() ? ` ${CLASSES.cookie.bannerClosing}` : ""}`,
}}
Expand All @@ -253,7 +258,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{...{ class: CLASSES.cookie.row }}
>
<p
id="cookie-consent-message"
id={controlId("message")}
{...{ class: CLASSES.cookie.message }}
>
{texts().message}
Expand All @@ -264,6 +269,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{...{ class: CLASSES.cookie.actions }}
>
<Button
id={controlId("accept-all")}
flavor="primary"
size="sm"
{...{ class: CLASSES.cookie.actionButton }}
Expand All @@ -272,6 +278,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{texts().acceptAll}
</Button>
<Button
id={controlId("decline")}
variant="ghost"
size="sm"
{...{ class: CLASSES.cookie.actionButton }}
Expand All @@ -280,6 +287,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{texts().decline}
</Button>
<Button
id={controlId("manage")}
type="button"
variant="ghost"
size="sm"
Expand All @@ -296,9 +304,10 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {

<Show when={showManage()}>
<div
id={controlId("dialog")}
role="dialog"
aria-modal="true"
aria-labelledby="cookie-manage-title"
aria-labelledby={controlId("title")}
{...{ class: CLASSES.cookie.modalBackdrop }}
onClick={handleBackdropClick}
>
Expand All @@ -312,12 +321,13 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{...{ class: CLASSES.cookie.modalHeader }}
>
<h2
id="cookie-manage-title"
id={controlId("title")}
{...{ class: CLASSES.cookie.modalTitle }}
>
{texts().manageTitle}
</h2>
<Button
id={controlId("close")}
size="sm"
variant="ghost"
width="square"
Expand All @@ -342,6 +352,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{texts().essential}
</span>
<input
id={controlId("essential")}
type="checkbox"
checked
disabled
Expand All @@ -355,6 +366,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{texts().analytics}
</span>
<input
id={controlId("analytics")}
type="checkbox"
checked={analyticsEnabled()}
onChange={(e) => setAnalyticsEnabled(e.currentTarget.checked)}
Expand All @@ -368,6 +380,7 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{texts().marketing}
</span>
<input
id={controlId("marketing")}
type="checkbox"
checked={marketingEnabled()}
onChange={(e) => setMarketingEnabled(e.currentTarget.checked)}
Expand All @@ -382,13 +395,15 @@ export const CookieConsent: Component<CookieConsentProps> = (props) => {
{...{ class: CLASSES.cookie.modalFooter }}
>
<Button
id={controlId("cancel")}
variant="ghost"
size="sm"
onClick={handleManageClose}
>
{texts().cancel}
</Button>
<Button
id={controlId("save")}
flavor="primary"
size="sm"
{...{ class: CLASSES.cookie.saveButton }}
Expand Down
Loading
Loading