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, …). diff --git a/layouts.library.json b/layouts.library.json index 0f9ad876..62d7815b 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", + "AuthCard", "AuthErrorMessage", "AuthFieldGroup", "AuthFooterLinks", "AuthForm", "AuthPoweredBy", "AuthSubmitButton", "AuthSuccessMessage", "Avatar", "AvatarFallback", "AvatarImage", "AvatarRoot", "Badge", "Breadcrumbs", "BreadcrumbsItem", "BreadcrumbsRoot", "Button", "ButtonGroup", "ButtonGroupRoot", "ButtonGroupSeparator", @@ -26,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", "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/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"; 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"; 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"; 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"; 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"; 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"; 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"; 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"; 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/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"; 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;