From f7bddade844b399d16379829d49cb7f22132d4e7 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:20:06 +0700 Subject: [PATCH 01/11] feat(auth-field-group): author as a Layout AuthFieldGroup was exported from the package but absent from the Layout manifest, so the application compiler rejected every consumer that imported it. Presentation moves from inline Tailwind into the recipe, and the gap axis becomes a declared presentation prop rather than a local class record. --- layouts.library.json | 1 + .../auth-field-group/AuthFieldGroup.css | 19 ++++++++++ .../AuthFieldGroup.layout.tsx | 23 +++++++++++ .../auth-field-group/AuthFieldGroup.recipe.ts | 15 ++++++++ .../auth-field-group/AuthFieldGroup.tsx | 38 ------------------- src/components/auth-field-group/index.ts | 6 ++- 6 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 src/components/auth-field-group/AuthFieldGroup.css create mode 100644 src/components/auth-field-group/AuthFieldGroup.layout.tsx create mode 100644 src/components/auth-field-group/AuthFieldGroup.recipe.ts delete mode 100644 src/components/auth-field-group/AuthFieldGroup.tsx diff --git a/layouts.library.json b/layouts.library.json index 0f9ad876..92c60454 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,6 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", + "AuthFieldGroup", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-field-group/AuthFieldGroup.css b/src/components/auth-field-group/AuthFieldGroup.css new file mode 100644 index 00000000..56a6034a --- /dev/null +++ b/src/components/auth-field-group/AuthFieldGroup.css @@ -0,0 +1,19 @@ +@layer components { + .auth-field-group { + display: flex; + width: 100%; + flex-direction: column; + } + + .auth-field-group--sm { + gap: 0.75rem; + } + + .auth-field-group--md { + gap: 1rem; + } + + .auth-field-group--lg { + gap: 1.5rem; + } +} diff --git a/src/components/auth-field-group/AuthFieldGroup.layout.tsx b/src/components/auth-field-group/AuthFieldGroup.layout.tsx new file mode 100644 index 00000000..48c340a7 --- /dev/null +++ b/src/components/auth-field-group/AuthFieldGroup.layout.tsx @@ -0,0 +1,23 @@ +import "./AuthFieldGroup.css"; +import type { JSX } from "solid-js"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authFieldGroup } from "./AuthFieldGroup.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthFieldGroupGap = "sm" | "md" | "lg"; + +export type AuthFieldGroupProps = Omit, "children"> & + IComponentBaseProps & { + children: JSX.Element; + gap?: AuthFieldGroupGap; + }; + +/* ------------------------------------------------------------------------------------------------- + * AuthFieldGroup + * -----------------------------------------------------------------------------------------------*/ +export const AuthFieldGroupLayout: Layout = () => ( +
{children}
+); diff --git a/src/components/auth-field-group/AuthFieldGroup.recipe.ts b/src/components/auth-field-group/AuthFieldGroup.recipe.ts new file mode 100644 index 00000000..57c8cf0c --- /dev/null +++ b/src/components/auth-field-group/AuthFieldGroup.recipe.ts @@ -0,0 +1,15 @@ +import { recipe } from "../../lib/layouts"; + +export const authFieldGroup = recipe({ + component: "auth-field-group", + element: "div", + slots: { root: { base: "auth-field-group" } }, + props: { + gap: { + sm: "auth-field-group--sm", + md: "auth-field-group--md", + lg: "auth-field-group--lg", + }, + }, + defaults: { gap: "md" }, +}); diff --git a/src/components/auth-field-group/AuthFieldGroup.tsx b/src/components/auth-field-group/AuthFieldGroup.tsx deleted file mode 100644 index 37bbbd2c..00000000 --- a/src/components/auth-field-group/AuthFieldGroup.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import type { IComponentBaseProps } from "../types"; - -export type AuthFieldGroupProps = Omit, "children"> & - IComponentBaseProps & { - children: JSX.Element; - gap?: "sm" | "md" | "lg"; - }; - -const GAP_CLASS: Record, string> = { - sm: "gap-3", - md: "gap-4", - lg: "gap-6", -}; - -const AuthFieldGroup: Component = (props) => { - const [local, others] = splitProps(props, [ - "children", - "gap", - "class", - "className", - "dataTheme", - ]); - - return ( -
- {local.children} -
- ); -}; - -export default AuthFieldGroup; diff --git a/src/components/auth-field-group/index.ts b/src/components/auth-field-group/index.ts index d333479c..b7fc5d1a 100644 --- a/src/components/auth-field-group/index.ts +++ b/src/components/auth-field-group/index.ts @@ -1 +1,5 @@ -export { default as AuthFieldGroup, type AuthFieldGroupProps } from "./AuthFieldGroup"; +export { + AuthFieldGroupLayout as default, + AuthFieldGroupLayout as AuthFieldGroup, +} from "./AuthFieldGroup.generated"; +export type { AuthFieldGroupProps, AuthFieldGroupGap } from "./AuthFieldGroup.generated"; From 565f6b225bf1c1b236690dfef8495f3d73b5183e Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:36 +0700 Subject: [PATCH 02/11] feat(auth-form): author as a Layout The gap and full-width sizing move out of inline utilities and into the recipe, so a consumer no longer restates the form's spacing to match it. --- layouts.library.json | 2 +- src/components/auth-form/AuthForm.css | 8 ++++ src/components/auth-form/AuthForm.layout.tsx | 30 +++++++++++++++ src/components/auth-form/AuthForm.recipe.ts | 7 ++++ src/components/auth-form/AuthForm.tsx | 40 -------------------- src/components/auth-form/index.ts | 3 +- 6 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 src/components/auth-form/AuthForm.css create mode 100644 src/components/auth-form/AuthForm.layout.tsx create mode 100644 src/components/auth-form/AuthForm.recipe.ts delete mode 100644 src/components/auth-form/AuthForm.tsx diff --git a/layouts.library.json b/layouts.library.json index 92c60454..ff521e33 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthFieldGroup", + "AuthFieldGroup", "AuthForm", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-form/AuthForm.css b/src/components/auth-form/AuthForm.css new file mode 100644 index 00000000..dbbfdc4d --- /dev/null +++ b/src/components/auth-form/AuthForm.css @@ -0,0 +1,8 @@ +@layer components { + .auth-form { + display: flex; + width: 100%; + flex-direction: column; + gap: 1rem; + } +} diff --git a/src/components/auth-form/AuthForm.layout.tsx b/src/components/auth-form/AuthForm.layout.tsx new file mode 100644 index 00000000..e91c976b --- /dev/null +++ b/src/components/auth-form/AuthForm.layout.tsx @@ -0,0 +1,30 @@ +import "./AuthForm.css"; +import type { JSX } from "solid-js"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authForm } from "./AuthForm.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthFormProps = Omit, "onSubmit" | "children"> & + IComponentBaseProps & { + children: JSX.Element; + onSubmit?: (event: SubmitEvent) => void | Promise; + ariaLabel?: string; + }; + +/* ------------------------------------------------------------------------------------------------- + * AuthForm + * -----------------------------------------------------------------------------------------------*/ +export const AuthFormLayout: Layout = () => { + const handleSubmit: JSX.EventHandlerUnion = (event) => { + void local.onSubmit?.(event as SubmitEvent); + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/auth-form/AuthForm.recipe.ts b/src/components/auth-form/AuthForm.recipe.ts new file mode 100644 index 00000000..c3424772 --- /dev/null +++ b/src/components/auth-form/AuthForm.recipe.ts @@ -0,0 +1,7 @@ +import { recipe } from "../../lib/layouts"; + +export const authForm = recipe({ + component: "auth-form", + element: "form", + slots: { root: { base: "auth-form" } }, +}); diff --git a/src/components/auth-form/AuthForm.tsx b/src/components/auth-form/AuthForm.tsx deleted file mode 100644 index 97d32ad8..00000000 --- a/src/components/auth-form/AuthForm.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import type { IComponentBaseProps } from "../types"; - -export type AuthFormProps = Omit, "onSubmit" | "children"> & - IComponentBaseProps & { - children: JSX.Element; - onSubmit?: (event: SubmitEvent) => void | Promise; - ariaLabel?: string; - }; - -const AuthForm: Component = (props) => { - const [local, others] = splitProps(props, [ - "children", - "onSubmit", - "ariaLabel", - "class", - "className", - "dataTheme", - ]); - - const handleSubmit: JSX.EventHandlerUnion = (event) => { - local.onSubmit?.(event as SubmitEvent); - }; - - return ( -
- {local.children} -
- ); -}; - -export default AuthForm; diff --git a/src/components/auth-form/index.ts b/src/components/auth-form/index.ts index 669c68c5..5840ed17 100644 --- a/src/components/auth-form/index.ts +++ b/src/components/auth-form/index.ts @@ -1 +1,2 @@ -export { default as AuthForm, type AuthFormProps } from "./AuthForm"; +export { AuthFormLayout as default, AuthFormLayout as AuthForm } from "./AuthForm.generated"; +export type { AuthFormProps } from "./AuthForm.generated"; From 0be703c810887763e71b24d866571f40fa5e5e77 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:36 +0700 Subject: [PATCH 03/11] feat(auth-error-message): author as a Layout Delegates to Alert with status=danger as before. The type scale is the recipe's now rather than a call-site class. --- layouts.library.json | 2 +- .../auth-error-message/AuthErrorMessage.css | 6 ++++ .../AuthErrorMessage.layout.tsx | 24 +++++++++++++++ .../AuthErrorMessage.recipe.ts | 7 +++++ .../auth-error-message/AuthErrorMessage.tsx | 29 ------------------- src/components/auth-error-message/index.ts | 6 +++- 6 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 src/components/auth-error-message/AuthErrorMessage.css create mode 100644 src/components/auth-error-message/AuthErrorMessage.layout.tsx create mode 100644 src/components/auth-error-message/AuthErrorMessage.recipe.ts delete mode 100644 src/components/auth-error-message/AuthErrorMessage.tsx diff --git a/layouts.library.json b/layouts.library.json index ff521e33..073aa8e0 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthFieldGroup", "AuthForm", + "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-error-message/AuthErrorMessage.css b/src/components/auth-error-message/AuthErrorMessage.css new file mode 100644 index 00000000..f8da1a6a --- /dev/null +++ b/src/components/auth-error-message/AuthErrorMessage.css @@ -0,0 +1,6 @@ +@layer components { + .auth-error-message { + font-size: 0.875rem; + line-height: 1.25rem; + } +} diff --git a/src/components/auth-error-message/AuthErrorMessage.layout.tsx b/src/components/auth-error-message/AuthErrorMessage.layout.tsx new file mode 100644 index 00000000..11b655b4 --- /dev/null +++ b/src/components/auth-error-message/AuthErrorMessage.layout.tsx @@ -0,0 +1,24 @@ +import "./AuthErrorMessage.css"; +import { Show, type JSX } from "solid-js"; +import Alert from "../alert"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authErrorMessage } from "./AuthErrorMessage.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthErrorMessageProps = IComponentBaseProps & { + message?: JSX.Element | string | null; +}; + +/* ------------------------------------------------------------------------------------------------- + * AuthErrorMessage + * -----------------------------------------------------------------------------------------------*/ +export const AuthErrorMessageLayout: Layout = () => ( + + + {local.message} + + +); diff --git a/src/components/auth-error-message/AuthErrorMessage.recipe.ts b/src/components/auth-error-message/AuthErrorMessage.recipe.ts new file mode 100644 index 00000000..540a11a2 --- /dev/null +++ b/src/components/auth-error-message/AuthErrorMessage.recipe.ts @@ -0,0 +1,7 @@ +import { recipe } from "../../lib/layouts"; + +export const authErrorMessage = recipe({ + component: "auth-error-message", + element: "div", + slots: { root: { base: "auth-error-message" } }, +}); diff --git a/src/components/auth-error-message/AuthErrorMessage.tsx b/src/components/auth-error-message/AuthErrorMessage.tsx deleted file mode 100644 index 8f63bbfc..00000000 --- a/src/components/auth-error-message/AuthErrorMessage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Show, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Alert from "../alert"; -import type { IComponentBaseProps } from "../types"; - -export type AuthErrorMessageProps = IComponentBaseProps & { - message?: JSX.Element | string | null; -}; - -const AuthErrorMessage: Component = (props) => { - const [local, others] = splitProps(props, ["message", "class", "className", "dataTheme"]); - - return ( - - - {local.message} - - - ); -}; - -export default AuthErrorMessage; diff --git a/src/components/auth-error-message/index.ts b/src/components/auth-error-message/index.ts index 7c157432..78320a3b 100644 --- a/src/components/auth-error-message/index.ts +++ b/src/components/auth-error-message/index.ts @@ -1 +1,5 @@ -export { default as AuthErrorMessage, type AuthErrorMessageProps } from "./AuthErrorMessage"; +export { + AuthErrorMessageLayout as default, + AuthErrorMessageLayout as AuthErrorMessage, +} from "./AuthErrorMessage.generated"; +export type { AuthErrorMessageProps } from "./AuthErrorMessage.generated"; From 5ec5eda0cbc1df89db479a889863cfe2ba0b7b30 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:36 +0700 Subject: [PATCH 04/11] feat(auth-success-message): author as a Layout Mirrors AuthErrorMessage: Alert with status=success, role=status and aria-live=polite so a screen reader announces it without stealing focus. --- layouts.library.json | 2 +- .../AuthSuccessMessage.css | 6 ++++ .../AuthSuccessMessage.layout.tsx | 27 +++++++++++++++++ .../AuthSuccessMessage.recipe.ts | 7 +++++ .../AuthSuccessMessage.tsx | 30 ------------------- src/components/auth-success-message/index.ts | 6 +++- 6 files changed, 46 insertions(+), 32 deletions(-) create mode 100644 src/components/auth-success-message/AuthSuccessMessage.css create mode 100644 src/components/auth-success-message/AuthSuccessMessage.layout.tsx create mode 100644 src/components/auth-success-message/AuthSuccessMessage.recipe.ts delete mode 100644 src/components/auth-success-message/AuthSuccessMessage.tsx diff --git a/layouts.library.json b/layouts.library.json index 073aa8e0..236170a9 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthErrorMessage", "AuthFieldGroup", "AuthForm", + "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-success-message/AuthSuccessMessage.css b/src/components/auth-success-message/AuthSuccessMessage.css new file mode 100644 index 00000000..504dba86 --- /dev/null +++ b/src/components/auth-success-message/AuthSuccessMessage.css @@ -0,0 +1,6 @@ +@layer components { + .auth-success-message { + font-size: 0.875rem; + line-height: 1.25rem; + } +} diff --git a/src/components/auth-success-message/AuthSuccessMessage.layout.tsx b/src/components/auth-success-message/AuthSuccessMessage.layout.tsx new file mode 100644 index 00000000..db5e6156 --- /dev/null +++ b/src/components/auth-success-message/AuthSuccessMessage.layout.tsx @@ -0,0 +1,27 @@ +import "./AuthSuccessMessage.css"; +import { Show, type JSX } from "solid-js"; +import Alert from "../alert"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authSuccessMessage } from "./AuthSuccessMessage.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthSuccessMessageProps = IComponentBaseProps & { + message?: JSX.Element | string | null; +}; + +/* ------------------------------------------------------------------------------------------------- + * AuthSuccessMessage + * -----------------------------------------------------------------------------------------------*/ +export const AuthSuccessMessageLayout: Layout< + typeof authSuccessMessage, + AuthSuccessMessageProps +> = () => ( + + + {local.message} + + +); diff --git a/src/components/auth-success-message/AuthSuccessMessage.recipe.ts b/src/components/auth-success-message/AuthSuccessMessage.recipe.ts new file mode 100644 index 00000000..c1691a8b --- /dev/null +++ b/src/components/auth-success-message/AuthSuccessMessage.recipe.ts @@ -0,0 +1,7 @@ +import { recipe } from "../../lib/layouts"; + +export const authSuccessMessage = recipe({ + component: "auth-success-message", + element: "div", + slots: { root: { base: "auth-success-message" } }, +}); diff --git a/src/components/auth-success-message/AuthSuccessMessage.tsx b/src/components/auth-success-message/AuthSuccessMessage.tsx deleted file mode 100644 index 219086de..00000000 --- a/src/components/auth-success-message/AuthSuccessMessage.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Show, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Alert from "../alert"; -import type { IComponentBaseProps } from "../types"; - -export type AuthSuccessMessageProps = IComponentBaseProps & { - message?: JSX.Element | string | null; -}; - -const AuthSuccessMessage: Component = (props) => { - const [local, others] = splitProps(props, ["message", "class", "className", "dataTheme"]); - - return ( - - - {local.message} - - - ); -}; - -export default AuthSuccessMessage; diff --git a/src/components/auth-success-message/index.ts b/src/components/auth-success-message/index.ts index 9331e797..162dc04c 100644 --- a/src/components/auth-success-message/index.ts +++ b/src/components/auth-success-message/index.ts @@ -1 +1,5 @@ -export { default as AuthSuccessMessage, type AuthSuccessMessageProps } from "./AuthSuccessMessage"; +export { + AuthSuccessMessageLayout as default, + AuthSuccessMessageLayout as AuthSuccessMessage, +} from "./AuthSuccessMessage.generated"; +export type { AuthSuccessMessageProps } from "./AuthSuccessMessage.generated"; From d9aae54f8bc8da53c1b2c5fc947b926814beec32 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:36 +0700 Subject: [PATCH 05/11] feat(auth-submit-button): author as a Layout Drops the variant translation helper: the four variants it accepted were already named exactly as Button names them, so it forwards instead of mapping. --- layouts.library.json | 2 +- .../auth-submit-button/AuthSubmitButton.css | 5 ++ .../AuthSubmitButton.layout.tsx | 46 ++++++++++++++++ .../AuthSubmitButton.recipe.ts | 7 +++ .../auth-submit-button/AuthSubmitButton.tsx | 52 ------------------- src/components/auth-submit-button/index.ts | 9 +++- 6 files changed, 67 insertions(+), 54 deletions(-) create mode 100644 src/components/auth-submit-button/AuthSubmitButton.css create mode 100644 src/components/auth-submit-button/AuthSubmitButton.layout.tsx create mode 100644 src/components/auth-submit-button/AuthSubmitButton.recipe.ts delete mode 100644 src/components/auth-submit-button/AuthSubmitButton.tsx diff --git a/layouts.library.json b/layouts.library.json index 236170a9..6f997f51 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthSuccessMessage", + "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthSubmitButton", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-submit-button/AuthSubmitButton.css b/src/components/auth-submit-button/AuthSubmitButton.css new file mode 100644 index 00000000..8fe6ce03 --- /dev/null +++ b/src/components/auth-submit-button/AuthSubmitButton.css @@ -0,0 +1,5 @@ +@layer components { + .auth-submit-button { + margin-block-start: 0; + } +} diff --git a/src/components/auth-submit-button/AuthSubmitButton.layout.tsx b/src/components/auth-submit-button/AuthSubmitButton.layout.tsx new file mode 100644 index 00000000..fa21ffb4 --- /dev/null +++ b/src/components/auth-submit-button/AuthSubmitButton.layout.tsx @@ -0,0 +1,46 @@ +import "./AuthSubmitButton.css"; +import type { JSX } from "solid-js"; +import Button from "../button"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authSubmitButton } from "./AuthSubmitButton.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthSubmitButtonVariant = "primary" | "secondary" | "ghost" | "danger"; + +export type AuthSubmitButtonProps = Omit< + JSX.ButtonHTMLAttributes, + "children" | "disabled" +> & + IComponentBaseProps & { + children: JSX.Element; + pending?: boolean; + disabled?: boolean; + variant?: AuthSubmitButtonVariant; + fullWidth?: boolean; + }; + +/* ------------------------------------------------------------------------------------------------- + * AuthSubmitButton + * + * The four accepted variants are named exactly as Button names them, so this + * forwards rather than translating. Pending implies disabled: a submit button + * that is spinning must not accept a second submission. + * -----------------------------------------------------------------------------------------------*/ +export const AuthSubmitButtonLayout: Layout< + typeof authSubmitButton, + AuthSubmitButtonProps +> = () => ( + +); diff --git a/src/components/auth-submit-button/AuthSubmitButton.recipe.ts b/src/components/auth-submit-button/AuthSubmitButton.recipe.ts new file mode 100644 index 00000000..426a091c --- /dev/null +++ b/src/components/auth-submit-button/AuthSubmitButton.recipe.ts @@ -0,0 +1,7 @@ +import { recipe } from "../../lib/layouts"; + +export const authSubmitButton = recipe({ + component: "auth-submit-button", + element: "button", + slots: { root: { base: "auth-submit-button" } }, +}); diff --git a/src/components/auth-submit-button/AuthSubmitButton.tsx b/src/components/auth-submit-button/AuthSubmitButton.tsx deleted file mode 100644 index a5d5e3c6..00000000 --- a/src/components/auth-submit-button/AuthSubmitButton.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Button from "../button"; -import type { ButtonVariant } from "../button"; -import type { IComponentBaseProps } from "../types"; - -export type AuthSubmitButtonProps = Omit, "children" | "disabled"> & - IComponentBaseProps & { - children: JSX.Element; - pending?: boolean; - disabled?: boolean; - variant?: "primary" | "secondary" | "ghost" | "danger"; - fullWidth?: boolean; - }; - -const toButtonVariant = (variant: NonNullable): ButtonVariant => { - if (variant === "danger") return "danger"; - if (variant === "ghost") return "ghost"; - if (variant === "secondary") return "secondary"; - return "primary"; -}; - -const AuthSubmitButton: Component = (props) => { - const [local, others] = splitProps(props, [ - "children", - "pending", - "disabled", - "variant", - "fullWidth", - "class", - "className", - "dataTheme", - ]); - - return ( - - ); -}; - -export default AuthSubmitButton; diff --git a/src/components/auth-submit-button/index.ts b/src/components/auth-submit-button/index.ts index d89a7a34..29c93bbc 100644 --- a/src/components/auth-submit-button/index.ts +++ b/src/components/auth-submit-button/index.ts @@ -1 +1,8 @@ -export { default as AuthSubmitButton, type AuthSubmitButtonProps } from "./AuthSubmitButton"; +export { + AuthSubmitButtonLayout as default, + AuthSubmitButtonLayout as AuthSubmitButton, +} from "./AuthSubmitButton.generated"; +export type { + AuthSubmitButtonProps, + AuthSubmitButtonVariant, +} from "./AuthSubmitButton.generated"; From fb1ca06d4fbad725ccc072677b619ed460ece1e0 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:54 +0700 Subject: [PATCH 06/11] feat(auth-powered-by): author as a Layout The align and variant axes become declared presentation parameters instead of two local class records, so the combination is resolved from one compiled table. --- layouts.library.json | 2 +- .../auth-powered-by/AuthPoweredBy.css | 61 +++++++++++++++ .../auth-powered-by/AuthPoweredBy.layout.tsx | 45 +++++++++++ .../auth-powered-by/AuthPoweredBy.recipe.ts | 25 ++++++ .../auth-powered-by/AuthPoweredBy.tsx | 76 ------------------- src/components/auth-powered-by/index.ts | 10 ++- 6 files changed, 141 insertions(+), 78 deletions(-) create mode 100644 src/components/auth-powered-by/AuthPoweredBy.css create mode 100644 src/components/auth-powered-by/AuthPoweredBy.layout.tsx create mode 100644 src/components/auth-powered-by/AuthPoweredBy.recipe.ts delete mode 100644 src/components/auth-powered-by/AuthPoweredBy.tsx diff --git a/layouts.library.json b/layouts.library.json index 6f997f51..a41250e3 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthSubmitButton", "AuthSuccessMessage", + "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-powered-by/AuthPoweredBy.css b/src/components/auth-powered-by/AuthPoweredBy.css new file mode 100644 index 00000000..e3491025 --- /dev/null +++ b/src/components/auth-powered-by/AuthPoweredBy.css @@ -0,0 +1,61 @@ +@layer components { + .auth-powered-by { + display: flex; + width: 100%; + } + + .auth-powered-by--left { + justify-content: flex-start; + text-align: left; + } + + .auth-powered-by--center { + justify-content: center; + text-align: center; + } + + .auth-powered-by--right { + justify-content: flex-end; + text-align: right; + } + + .auth-powered-by--subtle { + font-size: 0.75rem; + line-height: 1rem; + opacity: 0.7; + } + + .auth-powered-by--card { + border: 1px solid var(--color-base-300); + border-radius: 0.375rem; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + line-height: 1rem; + } + + .auth-powered-by--inline { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .auth-powered-by__link { + text-underline-offset: 4px; + transition-property: color, text-decoration-color; + transition-duration: 150ms; + } + + .auth-powered-by__link:hover { + text-decoration-line: underline; + } + + .auth-powered-by__content { + display: inline-flex; + align-items: center; + gap: 0.375rem; + } + + .auth-powered-by__logo { + display: inline-flex; + flex-shrink: 0; + } +} diff --git a/src/components/auth-powered-by/AuthPoweredBy.layout.tsx b/src/components/auth-powered-by/AuthPoweredBy.layout.tsx new file mode 100644 index 00000000..e9b316a1 --- /dev/null +++ b/src/components/auth-powered-by/AuthPoweredBy.layout.tsx @@ -0,0 +1,45 @@ +import "./AuthPoweredBy.css"; +import { Show, type JSX } from "solid-js"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authPoweredBy } from "./AuthPoweredBy.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthPoweredByAlign = "left" | "center" | "right"; +export type AuthPoweredByVariant = "subtle" | "card" | "inline"; + +export type AuthPoweredByProps = IComponentBaseProps & { + label?: string; + logo?: JSX.Element; + href?: string; + align?: AuthPoweredByAlign; + variant?: AuthPoweredByVariant; +}; + +const DEFAULT_LABEL = "Secure Auth by Honey"; +const DEFAULT_HREF = "https://honey.id/"; + +/* ------------------------------------------------------------------------------------------------- + * AuthPoweredBy + * -----------------------------------------------------------------------------------------------*/ +export const AuthPoweredByLayout: Layout = () => ( + +); diff --git a/src/components/auth-powered-by/AuthPoweredBy.recipe.ts b/src/components/auth-powered-by/AuthPoweredBy.recipe.ts new file mode 100644 index 00000000..3a039626 --- /dev/null +++ b/src/components/auth-powered-by/AuthPoweredBy.recipe.ts @@ -0,0 +1,25 @@ +import { recipe } from "../../lib/layouts"; + +export const authPoweredBy = recipe({ + component: "auth-powered-by", + element: "div", + slots: { + root: { base: "auth-powered-by" }, + link: { base: "auth-powered-by__link" }, + content: { base: "auth-powered-by__content" }, + logo: { base: "auth-powered-by__logo" }, + }, + props: { + align: { + left: "auth-powered-by--left", + center: "auth-powered-by--center", + right: "auth-powered-by--right", + }, + variant: { + subtle: "auth-powered-by--subtle", + card: "auth-powered-by--card", + inline: "auth-powered-by--inline", + }, + }, + defaults: { align: "center", variant: "subtle" }, +}); diff --git a/src/components/auth-powered-by/AuthPoweredBy.tsx b/src/components/auth-powered-by/AuthPoweredBy.tsx deleted file mode 100644 index 681935ea..00000000 --- a/src/components/auth-powered-by/AuthPoweredBy.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Show, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import type { IComponentBaseProps } from "../types"; - -export type AuthPoweredByProps = IComponentBaseProps & { - label?: string; - logo?: JSX.Element; - href?: string; - align?: "center" | "left" | "right"; - variant?: "subtle" | "card" | "inline"; -}; - -const ALIGN_CLASS: Record, string> = { - left: "justify-start text-left", - center: "justify-center text-center", - right: "justify-end text-right", -}; - -const VARIANT_CLASS: Record, string> = { - subtle: "text-xs opacity-70", - card: "rounded-md border border-base-300 px-2.5 py-1 text-xs", - inline: "text-sm", -}; - -const AuthPoweredBy: Component = (props) => { - const [local, others] = splitProps(props, [ - "label", - "logo", - "href", - "align", - "variant", - "class", - "className", - "dataTheme", - ]); - - const label = () => local.label ?? "Secure Auth by Honey"; - const href = () => local.href ?? "https://honey.id/"; - - const content = () => ( - - - - - {label()} - - ); - - return ( - - ); -}; - -export default AuthPoweredBy; diff --git a/src/components/auth-powered-by/index.ts b/src/components/auth-powered-by/index.ts index 2d9ed03a..7c2e2400 100644 --- a/src/components/auth-powered-by/index.ts +++ b/src/components/auth-powered-by/index.ts @@ -1 +1,9 @@ -export { default as AuthPoweredBy, type AuthPoweredByProps } from "./AuthPoweredBy"; +export { + AuthPoweredByLayout as default, + AuthPoweredByLayout as AuthPoweredBy, +} from "./AuthPoweredBy.generated"; +export type { + AuthPoweredByProps, + AuthPoweredByAlign, + AuthPoweredByVariant, +} from "./AuthPoweredBy.generated"; From d49c299e798fcfe98d8d744035440a81987aedd9 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:54 +0700 Subject: [PATCH 07/11] feat(auth-footer-links): author as a Layout Per-item disabled state moves to a data-disabled attribute styled by the recipe rather than a class ternary at each branch. The selectors match =true explicitly: a recipe reports both directions, so a bare presence selector would also match the enabled case. --- layouts.library.json | 2 +- .../auth-footer-links/AuthFooterLinks.css | 53 ++++++++++++ .../AuthFooterLinks.layout.tsx | 69 +++++++++++++++ .../AuthFooterLinks.recipe.ts | 18 ++++ .../auth-footer-links/AuthFooterLinks.tsx | 85 ------------------- src/components/auth-footer-links/index.ts | 12 ++- 6 files changed, 149 insertions(+), 90 deletions(-) create mode 100644 src/components/auth-footer-links/AuthFooterLinks.css create mode 100644 src/components/auth-footer-links/AuthFooterLinks.layout.tsx create mode 100644 src/components/auth-footer-links/AuthFooterLinks.recipe.ts delete mode 100644 src/components/auth-footer-links/AuthFooterLinks.tsx diff --git a/layouts.library.json b/layouts.library.json index a41250e3..6545e013 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthErrorMessage", "AuthFieldGroup", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", + "AuthErrorMessage", "AuthFieldGroup", "AuthFooterLinks", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-footer-links/AuthFooterLinks.css b/src/components/auth-footer-links/AuthFooterLinks.css new file mode 100644 index 00000000..469e9435 --- /dev/null +++ b/src/components/auth-footer-links/AuthFooterLinks.css @@ -0,0 +1,53 @@ +@layer components { + .auth-footer-links { + display: flex; + width: 100%; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + } + + .auth-footer-links--left { + justify-content: flex-start; + text-align: left; + } + + .auth-footer-links--center { + justify-content: center; + text-align: center; + } + + .auth-footer-links--right { + justify-content: flex-end; + text-align: right; + } + + .auth-footer-links__link { + color: var(--color-primary); + text-underline-offset: 4px; + transition-property: color, text-decoration-color; + transition-duration: 150ms; + } + + .auth-footer-links__link:hover { + color: var(--color-accent); + text-decoration-line: underline; + } + + /* Presence-carrying selectors are avoided deliberately: the recipe reports + both directions, so `[data-disabled]` alone would also match "false". */ + .auth-footer-links__link[data-disabled="true"] { + color: inherit; + opacity: 0.5; + } + + a.auth-footer-links__link[data-disabled="true"] { + pointer-events: none; + } + + button.auth-footer-links__link[data-disabled="true"] { + cursor: not-allowed; + } +} diff --git a/src/components/auth-footer-links/AuthFooterLinks.layout.tsx b/src/components/auth-footer-links/AuthFooterLinks.layout.tsx new file mode 100644 index 00000000..08caf5d8 --- /dev/null +++ b/src/components/auth-footer-links/AuthFooterLinks.layout.tsx @@ -0,0 +1,69 @@ +import "./AuthFooterLinks.css"; +import { For, type JSX } from "solid-js"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authFooterLinks } from "./AuthFooterLinks.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthFooterLinkItem = { + key: string; + label: JSX.Element | string; + href?: string; + onClick?: () => void; + disabled?: boolean; +}; + +export type AuthFooterLinksAlign = "left" | "center" | "right"; + +export type AuthFooterLinksProps = Omit, "children"> & + IComponentBaseProps & { + items: AuthFooterLinkItem[]; + align?: AuthFooterLinksAlign; + }; + +/* ------------------------------------------------------------------------------------------------- + * AuthFooterLinks + * + * An item with an href is an anchor and an item without one is a button, so a + * link that navigates keeps middle-click and "open in new tab" while a link + * that only runs a handler stays a real button for assistive technology. + * Disabled state is mirrored to `data-disabled` rather than composed into the + * class string, so the recipe keeps ownership of presentation. + * -----------------------------------------------------------------------------------------------*/ +export const AuthFooterLinksLayout: Layout = () => ( + +); diff --git a/src/components/auth-footer-links/AuthFooterLinks.recipe.ts b/src/components/auth-footer-links/AuthFooterLinks.recipe.ts new file mode 100644 index 00000000..1dcea0d9 --- /dev/null +++ b/src/components/auth-footer-links/AuthFooterLinks.recipe.ts @@ -0,0 +1,18 @@ +import { recipe } from "../../lib/layouts"; + +export const authFooterLinks = recipe({ + component: "auth-footer-links", + element: "div", + slots: { + root: { base: "auth-footer-links" }, + link: { base: "auth-footer-links__link" }, + }, + props: { + align: { + left: "auth-footer-links--left", + center: "auth-footer-links--center", + right: "auth-footer-links--right", + }, + }, + defaults: { align: "center" }, +}); diff --git a/src/components/auth-footer-links/AuthFooterLinks.tsx b/src/components/auth-footer-links/AuthFooterLinks.tsx deleted file mode 100644 index 0c2bb1fa..00000000 --- a/src/components/auth-footer-links/AuthFooterLinks.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { For, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import type { IComponentBaseProps } from "../types"; - -export type AuthFooterLinkItem = { - key: string; - label: JSX.Element | string; - href?: string; - onClick?: () => void; - disabled?: boolean; -}; - -export type AuthFooterLinksProps = Omit, "children"> & - IComponentBaseProps & { - items: AuthFooterLinkItem[]; - align?: "left" | "center" | "right"; - }; - -const ALIGN_CLASS: Record, string> = { - left: "justify-start text-left", - center: "justify-center text-center", - right: "justify-end text-right", -}; - -const AuthFooterLinks: Component = (props) => { - const [local, others] = splitProps(props, [ - "items", - "align", - "class", - "className", - "dataTheme", - ]); - - return ( - - ); -}; - -export default AuthFooterLinks; diff --git a/src/components/auth-footer-links/index.ts b/src/components/auth-footer-links/index.ts index a12aeb9c..129a50fe 100644 --- a/src/components/auth-footer-links/index.ts +++ b/src/components/auth-footer-links/index.ts @@ -1,5 +1,9 @@ export { - default as AuthFooterLinks, - type AuthFooterLinksProps, - type AuthFooterLinkItem, -} from "./AuthFooterLinks"; + AuthFooterLinksLayout as default, + AuthFooterLinksLayout as AuthFooterLinks, +} from "./AuthFooterLinks.generated"; +export type { + AuthFooterLinksProps, + AuthFooterLinkItem, + AuthFooterLinksAlign, +} from "./AuthFooterLinks.generated"; From 142a9c18db3f928d22a0b75434e3ce873c61b43e Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:54 +0700 Subject: [PATCH 08/11] feat(auth-card): author as a Layout The header, headings, title, description and branding regions become named slots, so a consumer can target them without reaching for the Tailwind classes that used to be inlined here. bodyClass still merges over the body slot. --- layouts.library.json | 2 +- src/components/auth-card/AuthCard.css | 44 +++++++++++++ src/components/auth-card/AuthCard.layout.tsx | 53 ++++++++++++++++ src/components/auth-card/AuthCard.recipe.ts | 16 +++++ src/components/auth-card/AuthCard.tsx | 65 -------------------- src/components/auth-card/index.ts | 3 +- 6 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 src/components/auth-card/AuthCard.css create mode 100644 src/components/auth-card/AuthCard.layout.tsx create mode 100644 src/components/auth-card/AuthCard.recipe.ts delete mode 100644 src/components/auth-card/AuthCard.tsx diff --git a/layouts.library.json b/layouts.library.json index 6545e013..cbd796dc 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -5,7 +5,7 @@ "exports": [ "Accordion", "AccordionContent", "AccordionIndicator", "AccordionItem", "AccordionRoot", "AccordionTrigger", "Alert", "AlertContent", "AlertDescription", "AlertIndicator", "AlertRoot", "AlertTitle", - "AuthErrorMessage", "AuthFieldGroup", "AuthFooterLinks", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", + "AuthCard", "AuthErrorMessage", "AuthFieldGroup", "AuthFooterLinks", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", diff --git a/src/components/auth-card/AuthCard.css b/src/components/auth-card/AuthCard.css new file mode 100644 index 00000000..31c180c9 --- /dev/null +++ b/src/components/auth-card/AuthCard.css @@ -0,0 +1,44 @@ +@layer components { + .auth-card { + width: 100%; + max-width: 28rem; + } + + .auth-card__body { + gap: 1.25rem; + } + + .auth-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + } + + .auth-card__headings { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .auth-card__title { + font-size: 1.25rem; + line-height: 1.25; + font-weight: 600; + } + + .auth-card__description { + font-size: 0.875rem; + line-height: 1.25rem; + opacity: 0.7; + } + + .auth-card__branding { + flex-shrink: 0; + } + + .auth-card__footer { + justify-content: flex-start; + } +} diff --git a/src/components/auth-card/AuthCard.layout.tsx b/src/components/auth-card/AuthCard.layout.tsx new file mode 100644 index 00000000..5d651ffd --- /dev/null +++ b/src/components/auth-card/AuthCard.layout.tsx @@ -0,0 +1,53 @@ +import "./AuthCard.css"; +import { Show, type JSX } from "solid-js"; +import { twMerge } from "tailwind-merge"; +import Card from "../card"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { authCard } from "./AuthCard.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type AuthCardProps = IComponentBaseProps & { + title?: JSX.Element; + description?: JSX.Element; + children: JSX.Element; + footer?: JSX.Element; + brandingSlot?: JSX.Element; + bodyClass?: string; +}; + +/* ------------------------------------------------------------------------------------------------- + * AuthCard + * + * The header block is omitted entirely when it would be empty, so a card with + * only a form does not carry a stray flex row that contributes gap. + * -----------------------------------------------------------------------------------------------*/ +export const AuthCardLayout: Layout = () => ( + + + +
+
+ +

{local.title}

+
+ +

{local.description}

+
+
+ +
{local.brandingSlot}
+
+
+
+ + {children} +
+ + + {local.footer} + +
+); diff --git a/src/components/auth-card/AuthCard.recipe.ts b/src/components/auth-card/AuthCard.recipe.ts new file mode 100644 index 00000000..7898ffe1 --- /dev/null +++ b/src/components/auth-card/AuthCard.recipe.ts @@ -0,0 +1,16 @@ +import { recipe } from "../../lib/layouts"; + +export const authCard = recipe({ + component: "auth-card", + element: "div", + slots: { + root: { base: "auth-card" }, + body: { base: "auth-card__body" }, + header: { base: "auth-card__header" }, + headings: { base: "auth-card__headings" }, + title: { base: "auth-card__title" }, + description: { base: "auth-card__description" }, + branding: { base: "auth-card__branding" }, + footer: { base: "auth-card__footer" }, + }, +}); diff --git a/src/components/auth-card/AuthCard.tsx b/src/components/auth-card/AuthCard.tsx deleted file mode 100644 index 1d1153d8..00000000 --- a/src/components/auth-card/AuthCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Show, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Card from "../card"; -import type { IComponentBaseProps } from "../types"; - -export type AuthCardProps = IComponentBaseProps & { - title?: JSX.Element; - description?: JSX.Element; - children: JSX.Element; - footer?: JSX.Element; - brandingSlot?: JSX.Element; - bodyClass?: string; -}; - -const AuthCard: Component = (props) => { - const [local, others] = splitProps(props, [ - "title", - "description", - "children", - "footer", - "brandingSlot", - "bodyClass", - "class", - "className", - "dataTheme", - ]); - - return ( - - - -
-
- -

{local.title}

-
- -

{local.description}

-
-
- -
{local.brandingSlot}
-
-
-
- - {local.children} -
- - - - {local.footer} - - -
- ); -}; - -export default AuthCard; diff --git a/src/components/auth-card/index.ts b/src/components/auth-card/index.ts index f316ea6e..7402db31 100644 --- a/src/components/auth-card/index.ts +++ b/src/components/auth-card/index.ts @@ -1 +1,2 @@ -export { default as AuthCard, type AuthCardProps } from "./AuthCard"; +export { AuthCardLayout as default, AuthCardLayout as AuthCard } from "./AuthCard.generated"; +export type { AuthCardProps } from "./AuthCard.generated"; From 0c05b228263e3ccc895705d62671d4f45f1556ff Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:26:54 +0700 Subject: [PATCH 09/11] feat(password-requirements): author as a Layout Met and unmet appearance moves onto data-passed and into the recipe, so the two states are described in one place instead of a class ternary per row. --- layouts.library.json | 2 +- .../PasswordRequirements.css | 42 +++++++++++ .../PasswordRequirements.layout.tsx | 66 +++++++++++++++++ .../PasswordRequirements.recipe.ts | 14 ++++ .../PasswordRequirements.tsx | 74 ------------------- src/components/password-requirements/index.ts | 6 +- 6 files changed, 128 insertions(+), 76 deletions(-) create mode 100644 src/components/password-requirements/PasswordRequirements.css create mode 100644 src/components/password-requirements/PasswordRequirements.layout.tsx create mode 100644 src/components/password-requirements/PasswordRequirements.recipe.ts delete mode 100644 src/components/password-requirements/PasswordRequirements.tsx diff --git a/layouts.library.json b/layouts.library.json index cbd796dc..c94589df 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -27,7 +27,7 @@ "MetalBorder", "Meter", "MeterFill", "MeterOutput", "MeterRoot", "MeterTrack", "Modal", "ModalBackdrop", "ModalBody", "ModalCloseTrigger", "ModalContent", "ModalFooter", "ModalHeader", "ModalHeading", "ModalIcon", "ModalRoot", "ModalTrigger", "Navbar", "NoiseBackground", "NumberField", "NumberFieldDecrementButton", "NumberFieldGroup", "NumberFieldIncrementButton", "NumberFieldInput", "NumberFieldRoot", - "Pagination", "Popover", "ProgressBar", "ProgressCircle", "Radio", "RadioGroup", "RangeCalendar", "ScrollShadow", + "Pagination", "PasswordRequirements", "Popover", "ProgressBar", "ProgressCircle", "Radio", "RadioGroup", "RangeCalendar", "ScrollShadow", "SearchField", "SearchFieldClearButton", "SearchFieldGroup", "SearchFieldInput", "SearchFieldRoot", "SearchFieldSearchIcon", "Select", "Separator", "SizePicker", "Skeleton", "Slider", "Spinner", "Surface", "Table", "TableExpandToggle", "TableInlineConfirm", "TableMobileListView", "TableSortIcon", "TableVirtualSpacerRow", diff --git a/src/components/password-requirements/PasswordRequirements.css b/src/components/password-requirements/PasswordRequirements.css new file mode 100644 index 00000000..7418cf18 --- /dev/null +++ b/src/components/password-requirements/PasswordRequirements.css @@ -0,0 +1,42 @@ +@layer components { + .password-requirements { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .password-requirements__title { + font-size: 0.75rem; + line-height: 1rem; + font-weight: 500; + text-transform: uppercase; + opacity: 0.7; + } + + .password-requirements__list { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .password-requirements__item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + opacity: 0.75; + } + + .password-requirements__item[data-passed="true"] { + color: var(--color-success); + opacity: 1; + } + + .password-requirements__icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + } +} diff --git a/src/components/password-requirements/PasswordRequirements.layout.tsx b/src/components/password-requirements/PasswordRequirements.layout.tsx new file mode 100644 index 00000000..31839486 --- /dev/null +++ b/src/components/password-requirements/PasswordRequirements.layout.tsx @@ -0,0 +1,66 @@ +import "./PasswordRequirements.css"; +import { For, Show, type JSX } from "solid-js"; +import { twMerge } from "tailwind-merge"; +import Icon from "../icon"; +import type { IComponentBaseProps } from "../types"; +import type { PasswordRuleResult } from "../../passwordRules"; +import type { Layout } from "../../lib/layouts"; +import { passwordRequirements } from "./PasswordRequirements.recipe"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type PasswordRequirementsProps = Omit, "children"> & + IComponentBaseProps & { + title?: JSX.Element; + results: PasswordRuleResult[]; + itemClass?: string; + metIcon?: JSX.Element; + unmetIcon?: JSX.Element; + }; + +/* ------------------------------------------------------------------------------------------------- + * PasswordRequirements + * + * Pass/fail is mirrored to `data-passed` and styled from the recipe, so the + * met and unmet appearances stay in one place rather than in a class ternary. + * -----------------------------------------------------------------------------------------------*/ +export const PasswordRequirementsLayout: Layout< + typeof passwordRequirements, + PasswordRequirementsProps +> = () => ( +
+ +

{local.title}

+
+ +
    + + {(rule) => ( +
  • + + {rule.message} +
  • + )} +
    +
+
+); diff --git a/src/components/password-requirements/PasswordRequirements.recipe.ts b/src/components/password-requirements/PasswordRequirements.recipe.ts new file mode 100644 index 00000000..9df4ad2d --- /dev/null +++ b/src/components/password-requirements/PasswordRequirements.recipe.ts @@ -0,0 +1,14 @@ +import { recipe } from "../../lib/layouts"; + +export const passwordRequirements = recipe({ + component: "password-requirements", + element: "div", + slots: { + root: { base: "password-requirements" }, + title: { base: "password-requirements__title" }, + list: { base: "password-requirements__list" }, + item: { base: "password-requirements__item" }, + icon: { base: "password-requirements__icon" }, + message: { base: "password-requirements__message" }, + }, +}); diff --git a/src/components/password-requirements/PasswordRequirements.tsx b/src/components/password-requirements/PasswordRequirements.tsx deleted file mode 100644 index ab21a9c6..00000000 --- a/src/components/password-requirements/PasswordRequirements.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { For, Show, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Icon from "../icon"; -import type { IComponentBaseProps } from "../types"; -import type { PasswordRuleResult } from "../../passwordRules"; - -export type PasswordRequirementsProps = Omit, "children"> & - IComponentBaseProps & { - title?: JSX.Element; - results: PasswordRuleResult[]; - itemClass?: string; - metIcon?: JSX.Element; - unmetIcon?: JSX.Element; - }; - -const PasswordRequirements: Component = (props) => { - const [local, others] = splitProps(props, [ - "title", - "results", - "itemClass", - "metIcon", - "unmetIcon", - "class", - "className", - "dataTheme", - ]); - - return ( -
- -

{local.title}

-
- -
    - - {(rule) => ( -
  • - - {rule.message} -
  • - )} -
    -
-
- ); -}; - -export default PasswordRequirements; diff --git a/src/components/password-requirements/index.ts b/src/components/password-requirements/index.ts index 4edae871..2e8496ba 100644 --- a/src/components/password-requirements/index.ts +++ b/src/components/password-requirements/index.ts @@ -1 +1,5 @@ -export { default as PasswordRequirements, type PasswordRequirementsProps } from "./PasswordRequirements"; +export { + PasswordRequirementsLayout as default, + PasswordRequirementsLayout as PasswordRequirements, +} from "./PasswordRequirements.generated"; +export type { PasswordRequirementsProps } from "./PasswordRequirements.generated"; From 3867f3d11dd345a260f27437c031dc146a418699 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:27:02 +0700 Subject: [PATCH 10/11] feat(password-field): author as a Layout The visibility helpers move to PasswordField.interactions.ts, following the split Slider already uses, and the tests import them from there. Deferring the post-toggle restore also moves into that module. A .layout.tsx template resolves free identifiers against props, so a bare queueMicrotask in the template compiles to props.queueMicrotask, which typechecks as unknown and fails. --- layouts.library.json | 2 +- .../password-field/PasswordField.css | 12 + .../PasswordField.interactions.ts | 141 ++++++++++ .../password-field/PasswordField.layout.tsx | 132 +++++++++ .../password-field/PasswordField.recipe.ts | 10 + .../password-field/PasswordField.tsx | 266 ------------------ src/components/password-field/index.ts | 16 +- .../password-field/PasswordField.test.tsx | 2 +- 8 files changed, 312 insertions(+), 269 deletions(-) create mode 100644 src/components/password-field/PasswordField.css create mode 100644 src/components/password-field/PasswordField.interactions.ts create mode 100644 src/components/password-field/PasswordField.layout.tsx create mode 100644 src/components/password-field/PasswordField.recipe.ts delete mode 100644 src/components/password-field/PasswordField.tsx diff --git a/layouts.library.json b/layouts.library.json index c94589df..62d7815b 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -27,7 +27,7 @@ "MetalBorder", "Meter", "MeterFill", "MeterOutput", "MeterRoot", "MeterTrack", "Modal", "ModalBackdrop", "ModalBody", "ModalCloseTrigger", "ModalContent", "ModalFooter", "ModalHeader", "ModalHeading", "ModalIcon", "ModalRoot", "ModalTrigger", "Navbar", "NoiseBackground", "NumberField", "NumberFieldDecrementButton", "NumberFieldGroup", "NumberFieldIncrementButton", "NumberFieldInput", "NumberFieldRoot", - "Pagination", "PasswordRequirements", "Popover", "ProgressBar", "ProgressCircle", "Radio", "RadioGroup", "RangeCalendar", "ScrollShadow", + "Pagination", "PasswordField", "PasswordRequirements", "Popover", "ProgressBar", "ProgressCircle", "Radio", "RadioGroup", "RangeCalendar", "ScrollShadow", "SearchField", "SearchFieldClearButton", "SearchFieldGroup", "SearchFieldInput", "SearchFieldRoot", "SearchFieldSearchIcon", "Select", "Separator", "SizePicker", "Skeleton", "Slider", "Spinner", "Surface", "Table", "TableExpandToggle", "TableInlineConfirm", "TableMobileListView", "TableSortIcon", "TableVirtualSpacerRow", diff --git a/src/components/password-field/PasswordField.css b/src/components/password-field/PasswordField.css new file mode 100644 index 00000000..0e1740cc --- /dev/null +++ b/src/components/password-field/PasswordField.css @@ -0,0 +1,12 @@ +@layer components { + .password-field { + width: 100%; + } + + .password-field__toggle { + height: 1.75rem; + min-height: 1.75rem; + width: 1.75rem; + min-width: 1.75rem; + } +} diff --git a/src/components/password-field/PasswordField.interactions.ts b/src/components/password-field/PasswordField.interactions.ts new file mode 100644 index 00000000..435649af --- /dev/null +++ b/src/components/password-field/PasswordField.interactions.ts @@ -0,0 +1,141 @@ +import type { JSX } from "solid-js"; +import { twMerge } from "tailwind-merge"; + +/** + * Pure helpers behind PasswordField's visibility toggle. + * + * They live apart from the Layout because they are the part worth testing + * directly: swapping an input's `type` is the one operation browsers and + * password managers treat as replacing the field, which loses focus, the + * selection, and occasionally the value itself. + */ + +export type PasswordToggleSnapshot = { + hadFocus: boolean; + selectionStart: number | null; + selectionEnd: number | null; + selectionDirection: "forward" | "backward" | "none" | null; + valueBeforeToggle: string | null; +}; + +export type PasswordFieldLike = { + value: string; + selectionStart: number | null; + selectionEnd: number | null; + selectionDirection: "forward" | "backward" | "none" | null; + focus: (options?: { preventScroll?: boolean }) => void; + setSelectionRange: ( + selectionStart: number, + selectionEnd: number, + selectionDirection?: "forward" | "backward" | "none", + ) => void; + dispatchEvent: (event: Event) => boolean; +}; + +export type PasswordFieldInputContractParams = { + id?: string; + name?: string; + label?: JSX.Element; + placeholder?: string; + required?: boolean; + autofocus?: boolean; + autocomplete?: "current-password" | "new-password" | "off"; + "aria-describedby"?: string; + value?: string; + disabled?: boolean; + invalid?: boolean; + startIcon?: JSX.Element; + inputClass?: string; + isVisible: boolean; +}; + +export const getPasswordInputType = (isVisible: boolean) => + isVisible ? "text" : "password"; + +export const createPasswordFieldInputContract = ( + params: PasswordFieldInputContractParams, +) => ({ + id: params.id, + name: params.name, + label: params.label, + type: getPasswordInputType(params.isVisible), + placeholder: params.placeholder, + required: params.required, + autofocus: params.autofocus, + autocomplete: params.autocomplete, + "aria-describedby": params["aria-describedby"], + value: params.value, + isDisabled: Boolean(params.disabled), + isInvalid: Boolean(params.invalid), + startIcon: params.startIcon, + class: twMerge("w-full", params.inputClass), +}); + +export const selectPasswordToggleIcon = ( + isVisible: boolean, + visibleIcon: JSX.Element | undefined, + hiddenIcon: JSX.Element | undefined, + fallback: JSX.Element, +) => (isVisible ? visibleIcon : hiddenIcon) ?? fallback; + +export const capturePasswordToggleSnapshot = ( + field: PasswordFieldLike | undefined, + activeElement: EventTarget | null, +): PasswordToggleSnapshot => ({ + // activeElement is EventTarget | null while field may be an HTMLInputElement-like object. + // Use a type-safe comparison by casting field to EventTarget for the runtime equality check. + hadFocus: activeElement === (field as unknown as EventTarget), + selectionStart: field?.selectionStart ?? null, + selectionEnd: field?.selectionEnd ?? null, + selectionDirection: field?.selectionDirection ?? null, + valueBeforeToggle: field?.value ?? null, +}); + +export const restorePasswordFieldAfterToggle = ( + field: PasswordFieldLike | undefined, + snapshot: PasswordToggleSnapshot, +) => { + if (!field) return; + + if ( + snapshot.valueBeforeToggle !== null && + field.value !== snapshot.valueBeforeToggle + ) { + field.value = snapshot.valueBeforeToggle; + field.dispatchEvent(new Event("input", { bubbles: true })); + } + + if (!snapshot.hadFocus) return; + + field.focus({ preventScroll: true }); + if (snapshot.selectionStart === null || snapshot.selectionEnd === null) + return; + + try { + field.setSelectionRange( + snapshot.selectionStart, + snapshot.selectionEnd, + snapshot.selectionDirection ?? undefined, + ); + } catch { + // Some browser/password input transitions can temporarily reject selection restoration. + } +}; + +/** + * Restore the field after the browser has processed the `type` swap. + * + * The deferral lives here rather than in the Layout because a `.layout.tsx` + * template resolves free identifiers against props: a bare `queueMicrotask` + * in that file compiles to `props.queueMicrotask`. + */ +export const schedulePasswordFieldRestore = ( + field: PasswordFieldLike | undefined, + snapshot: PasswordToggleSnapshot, +) => { + queueMicrotask(() => restorePasswordFieldAfterToggle(field, snapshot)); +}; + +export const preventPasswordTogglePointerDown = ( + event: Pick, +) => event.preventDefault(); diff --git a/src/components/password-field/PasswordField.layout.tsx b/src/components/password-field/PasswordField.layout.tsx new file mode 100644 index 00000000..daf50bf7 --- /dev/null +++ b/src/components/password-field/PasswordField.layout.tsx @@ -0,0 +1,132 @@ +import "./PasswordField.css"; +import { createSignal, type JSX } from "solid-js"; +import Button from "../button"; +import Icon from "../icon"; +import Input from "../input"; +import type { IComponentBaseProps } from "../types"; +import type { Layout } from "../../lib/layouts"; +import { passwordField } from "./PasswordField.recipe"; +import { + capturePasswordToggleSnapshot, + createPasswordFieldInputContract, + preventPasswordTogglePointerDown, + schedulePasswordFieldRestore, + selectPasswordToggleIcon, +} from "./PasswordField.interactions"; + +/* ------------------------------------------------------------------------------------------------- + * Types + * -----------------------------------------------------------------------------------------------*/ +export type PasswordFieldProps = IComponentBaseProps & { + id?: string; + name?: string; + label?: JSX.Element; + placeholder?: string; + disabled?: boolean; + invalid?: boolean; + required?: boolean; + autofocus?: boolean; + autocomplete?: "current-password" | "new-password" | "off"; + "aria-describedby"?: string; + startIcon?: JSX.Element; + showLabel: string; + hideLabel: string; + value?: string; + inputRef?: (el: HTMLInputElement) => void; + onInput?: (value: string) => void; + onBlur?: () => void; + visibleIcon?: JSX.Element; + hiddenIcon?: JSX.Element; + onVisibilityChange?: (visible: boolean) => void; + class?: string; + inputClass?: string; +}; + +/* ------------------------------------------------------------------------------------------------- + * PasswordField + * + * Visibility lives here rather than in the recipe because it changes the + * input's `type`, which is behaviour, not presentation. The snapshot around + * the toggle restores focus, selection and value: swapping `type` makes some + * browsers and password managers treat the field as replaced. + * -----------------------------------------------------------------------------------------------*/ +export const PasswordFieldLayout: Layout = () => { + const [isVisible, setIsVisible] = createSignal(false); + let fieldRef: HTMLInputElement | undefined; + + const toggleLabel = () => (isVisible() ? local.hideLabel : local.showLabel); + const toggleIcon = () => + selectPasswordToggleIcon( + isVisible(), + local.visibleIcon, + local.hiddenIcon, + , + ); + + const setFieldRef = (el: HTMLInputElement) => { + fieldRef = el; + local.inputRef?.(el); + }; + + const toggleVisibility = () => { + const snapshot = capturePasswordToggleSnapshot( + fieldRef, + typeof document !== "undefined" ? document.activeElement : null, + ); + const nextVisible = !isVisible(); + + setIsVisible(nextVisible); + local.onVisibilityChange?.(nextVisible); + + schedulePasswordFieldRestore(fieldRef, snapshot); + }; + + return ( +
+ { + local.onInput?.(event.currentTarget.value); + }} + onBlur={() => local.onBlur?.()} + endIcon={ + + } + /> +
+ ); +}; diff --git a/src/components/password-field/PasswordField.recipe.ts b/src/components/password-field/PasswordField.recipe.ts new file mode 100644 index 00000000..8d39643f --- /dev/null +++ b/src/components/password-field/PasswordField.recipe.ts @@ -0,0 +1,10 @@ +import { recipe } from "../../lib/layouts"; + +export const passwordField = recipe({ + component: "password-field", + element: "div", + slots: { + root: { base: "password-field" }, + toggle: { base: "password-field__toggle" }, + }, +}); diff --git a/src/components/password-field/PasswordField.tsx b/src/components/password-field/PasswordField.tsx deleted file mode 100644 index 3eca2161..00000000 --- a/src/components/password-field/PasswordField.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { createSignal, splitProps, type Component, type JSX } from "solid-js"; -import { twMerge } from "tailwind-merge"; -import Button from "../button"; -import Icon from "../icon"; -import Input from "../input"; -import type { IComponentBaseProps } from "../types"; - -export type PasswordFieldProps = IComponentBaseProps & { - id?: string; - name?: string; - label?: JSX.Element; - placeholder?: string; - disabled?: boolean; - invalid?: boolean; - required?: boolean; - autofocus?: boolean; - autocomplete?: "current-password" | "new-password" | "off"; - "aria-describedby"?: string; - startIcon?: JSX.Element; - showLabel: string; - hideLabel: string; - value?: string; - inputRef?: (el: HTMLInputElement) => void; - onInput?: (value: string) => void; - onBlur?: () => void; - visibleIcon?: JSX.Element; - hiddenIcon?: JSX.Element; - onVisibilityChange?: (visible: boolean) => void; - class?: string; - inputClass?: string; -}; - -export type PasswordToggleSnapshot = { - hadFocus: boolean; - selectionStart: number | null; - selectionEnd: number | null; - selectionDirection: "forward" | "backward" | "none" | null; - valueBeforeToggle: string | null; -}; - -type PasswordFieldInputContractParams = Pick< - PasswordFieldProps, - | "id" - | "name" - | "label" - | "placeholder" - | "required" - | "autofocus" - | "autocomplete" - | "aria-describedby" - | "value" - | "disabled" - | "invalid" - | "startIcon" - | "inputClass" -> & { - isVisible: boolean; -}; - -export const createPasswordFieldInputContract = ( - params: PasswordFieldInputContractParams, -) => ({ - id: params.id, - name: params.name, - label: params.label, - type: getPasswordInputType(params.isVisible), - placeholder: params.placeholder, - required: params.required, - autofocus: params.autofocus, - autocomplete: params.autocomplete, - "aria-describedby": params["aria-describedby"], - value: params.value, - isDisabled: Boolean(params.disabled), - isInvalid: Boolean(params.invalid), - startIcon: params.startIcon, - class: twMerge("w-full", params.inputClass), -}); - -type PasswordFieldLike = { - value: string; - selectionStart: number | null; - selectionEnd: number | null; - selectionDirection: "forward" | "backward" | "none" | null; - focus: (options?: { preventScroll?: boolean }) => void; - setSelectionRange: ( - selectionStart: number, - selectionEnd: number, - selectionDirection?: "forward" | "backward" | "none", - ) => void; - dispatchEvent: (event: Event) => boolean; -}; - -export const getPasswordInputType = (isVisible: boolean) => - isVisible ? "text" : "password"; - -export const selectPasswordToggleIcon = ( - isVisible: boolean, - visibleIcon: JSX.Element | undefined, - hiddenIcon: JSX.Element | undefined, - fallback: JSX.Element, -) => (isVisible ? visibleIcon : hiddenIcon) ?? fallback; - -export const capturePasswordToggleSnapshot = ( - field: PasswordFieldLike | undefined, - activeElement: EventTarget | null, -): PasswordToggleSnapshot => ({ - // activeElement is EventTarget | null while field may be an HTMLInputElement-like object. - // Use a type-safe comparison by casting field to EventTarget for the runtime equality check. - hadFocus: activeElement === (field as unknown as EventTarget), - selectionStart: field?.selectionStart ?? null, - selectionEnd: field?.selectionEnd ?? null, - selectionDirection: field?.selectionDirection ?? null, - valueBeforeToggle: field?.value ?? null, -}); - -export const restorePasswordFieldAfterToggle = ( - field: PasswordFieldLike | undefined, - snapshot: PasswordToggleSnapshot, -) => { - if (!field) return; - - if ( - snapshot.valueBeforeToggle !== null && - field.value !== snapshot.valueBeforeToggle - ) { - field.value = snapshot.valueBeforeToggle; - field.dispatchEvent(new Event("input", { bubbles: true })); - } - - if (!snapshot.hadFocus) return; - - field.focus({ preventScroll: true }); - if (snapshot.selectionStart === null || snapshot.selectionEnd === null) - return; - - try { - field.setSelectionRange( - snapshot.selectionStart, - snapshot.selectionEnd, - snapshot.selectionDirection ?? undefined, - ); - } catch { - // Some browser/password input transitions can temporarily reject selection restoration. - } -}; - -export const preventPasswordTogglePointerDown = ( - event: Pick, -) => event.preventDefault(); - -const PasswordField: Component = (props) => { - const [local] = splitProps(props, [ - "id", - "name", - "label", - "placeholder", - "disabled", - "invalid", - "required", - "autofocus", - "autocomplete", - "aria-describedby", - "startIcon", - "showLabel", - "hideLabel", - "value", - "inputRef", - "onInput", - "onBlur", - "visibleIcon", - "hiddenIcon", - "onVisibilityChange", - "inputClass", - "class", - "className", - "dataTheme", - ]); - - const [isVisible, setIsVisible] = createSignal(false); - let fieldRef: HTMLInputElement | undefined; - const toggleLabel = () => (isVisible() ? local.hideLabel : local.showLabel); - const toggleIcon = () => - selectPasswordToggleIcon( - isVisible(), - local.visibleIcon, - local.hiddenIcon, - , - ); - - const setFieldRef = (el: HTMLInputElement) => { - fieldRef = el; - local.inputRef?.(el); - }; - - const toggleVisibility = () => { - const snapshot = capturePasswordToggleSnapshot( - fieldRef, - typeof document !== "undefined" ? document.activeElement : null, - ); - const nextVisible = !isVisible(); - - setIsVisible(nextVisible); - local.onVisibilityChange?.(nextVisible); - - queueMicrotask(() => { - restorePasswordFieldAfterToggle(fieldRef, snapshot); - }); - }; - - return ( -
- { - local.onInput?.(event.currentTarget.value); - }} - onBlur={() => local.onBlur?.()} - endIcon={ - - } - /> -
- ); -}; - -export default PasswordField; diff --git a/src/components/password-field/index.ts b/src/components/password-field/index.ts index f4b5c35e..df08c663 100644 --- a/src/components/password-field/index.ts +++ b/src/components/password-field/index.ts @@ -1 +1,15 @@ -export { default as PasswordField, type PasswordFieldProps } from "./PasswordField"; +export { + PasswordFieldLayout as default, + PasswordFieldLayout as PasswordField, +} from "./PasswordField.generated"; +export type { PasswordFieldProps } from "./PasswordField.generated"; +export { + capturePasswordToggleSnapshot, + createPasswordFieldInputContract, + getPasswordInputType, + preventPasswordTogglePointerDown, + restorePasswordFieldAfterToggle, + selectPasswordToggleIcon, + type PasswordFieldLike, + type PasswordToggleSnapshot, +} from "./PasswordField.interactions"; diff --git a/tests/components/password-field/PasswordField.test.tsx b/tests/components/password-field/PasswordField.test.tsx index 4065256b..c7e92a29 100644 --- a/tests/components/password-field/PasswordField.test.tsx +++ b/tests/components/password-field/PasswordField.test.tsx @@ -8,7 +8,7 @@ import { restorePasswordFieldAfterToggle, selectPasswordToggleIcon, type PasswordToggleSnapshot, -} from "../../../src/components/password-field/PasswordField"; +} from "../../../src/components/password-field/PasswordField.interactions"; type MockField = { value: string; From 3ad30eab809e9cd62d08beab0b7e70ba2c962120 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 00:27:41 +0700 Subject: [PATCH 11/11] docs: describe the auth kit as Layouts The inventory still called these thin Tailwind-utility wrappers, which stopped being true when they gained recipes. Names the presentation parameters and AuthCard's slots so a consumer can find them without reading the source. --- docs/ui-usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 7d745c5a..d5da67af 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -95,7 +95,7 @@ the shared fallback. This works with PathScale Fonts and application-owned font - **Color**: ColorPicker, ColorArea, ColorSlider, ColorSwatch(+Picker), ColorWheelFlower, ThemeColorPicker - **Overlays**: Modal, Drawer, Popover, Dropdown, Menu, Toast, Disclosure(+Group), Accordion - **Data**: Table (headless compound + hooks), plus primitives `useVirtualRows`, `useStreamingBuffer`, `useStreamingSubscription` -- **Auth kit**: AuthForm, AuthCard, AuthFieldGroup, AuthSubmitButton, AuthFooterLinks, AuthPoweredBy, AuthErrorMessage, AuthSuccessMessage — thin Tailwind-utility wrappers composing Button/Card/fields +- **Auth kit**: AuthForm, AuthCard, AuthFieldGroup, AuthSubmitButton, AuthFooterLinks, AuthPoweredBy, AuthErrorMessage, AuthSuccessMessage — Layouts composing Button/Card/fields. Their spacing, alignment and tone are recipe parameters (`gap`, `align`, `variant`), so a consumer asks for the presentation it wants rather than restating utility classes. AuthCard exposes `header`, `headings`, `title`, `description`, `branding`, `body` and `footer` as `data-slot` targets. - **Visual FX**: MetalBorder (WebGL liquid-metal border; presets `chromatic|silver|gold`, `kind="pill"|"circle"`, `glow`, `strength` 0-100, `theme="dark"|"light"|"auto"`), GlowCard (mouse-tracking glow), NoiseBackground (animated gradient blobs), ImmersiveLanding (full mini-app w/ PWA widgets), VideoPreview, LiveChat, ChatBubble, LanguageSwitcher Renames from old versions (see `docs/component-migration-map.md`): Loading→Spinner, DropdownSelect→Select, RadialProgress→ProgressCircle, RangeSlider→Slider, Progress→ProgressBar/ProgressCircle. ~40 components removed outright (Carousel, Rating, Steps, Stats, FileInput, …).