diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e69bcee..36acf26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,16 @@ on: jobs: build: - # qa-inspect-host is a native renderer binary. Its currently published - # Linux runtime predates Tauri's GTK trait additions, while macOS is the - # supported build used by the host's own CI and by AgencyZero. - runs-on: macos-14 + # Linux, since `qa-inspect-host` 0.1.9. It could not be built here before: + # it reached the engine through `tauri-runtime-blitz`, which brought the + # Tauri runtime with it, and that does not compile on Linux at all -- its + # `RawWindow` is a `gtk::ApplicationWindow` there and a Blitz window has + # none. So this job ran on macOS to get a host at all. + # + # The host opens no window, and now does not compile one: it takes the + # runtime with `default-features = false`, so nothing in its graph is Tauri + # or GTK. macOS is for building packages, not for a component sweep. + runs-on: ubicloud-standard-2 steps: - uses: actions/checkout@v4 @@ -50,9 +56,18 @@ jobs: # Check, Build and the whole test suite were skipped rather than failed — # a green-looking pipeline that had verified nothing. # - # `--locked` uses each crate's own published lockfile, which is the set - # its author tested. It costs nothing in currency: both tools still - # install at their newest version. + # `--locked` is gone, and it did cost currency: a lockfile is frozen at + # publication, so the host was pinned to whatever engine existed the day + # it was released. A renderer fix could then never reach this gate without + # a host release, and that is not hypothetical -- `ps-blitz` 0.4.3 fixes + # three defects in checkbox activation, and the host published before it + # kept installing 0.4.2, so `switch`, `checkbox` and `radio` went on + # failing here against an engine that had already been fixed. + # + # The mismatch `--locked` was added for is gone with it: it happened when + # `qa-inspect-host` wanted `ps-blitz` 0.4.x while its `tauri-runtime-blitz` + # wanted 0.3.x, so the same type existed twice. Both now require `^0.4` + # and resolve to one copy. # # One command per tool, deliberately. `cargo install a b --locked` # resolves the two together and downgrades to satisfy both — it picks @@ -64,8 +79,16 @@ jobs: # which leaves rust-cache nothing useful to restore on the next run. CARGO_TARGET_DIR: target/qa-tools run: | - cargo install ps-qa --locked - cargo install qa-inspect-host --locked + # Pinned to a floor, not left to "latest". + # + # The sweep needs `--headless` and a host that can replace a text + # field without a font catalogue. Installing whatever is newest meant + # a version without those failed as `unexpected argument`, which + # reads as a broken workflow rather than a tool that is too old -- + # and a host that silently appended to a field would not have failed + # at all until a check disagreed about a value. + cargo install ps-qa --version "^0.6.2" + cargo install qa-inspect-host --version "^0.1.12" - name: Install Dependencies run: bun install @@ -91,16 +114,25 @@ jobs: # minutes while preserving a clean document between components. - name: Rendered component outcomes env: - # GitHub's shared macOS runner took 1.6-1.9s for outcomes that pass - # locally under 1.25s. This widens scheduling deadlines only; every + # A shared runner takes longer than a local machine for outcomes that pass + # locally well inside the budget. This widens scheduling deadlines only; every # declared rendered-state transition is still required. QA_TIMEOUT_SCALE: 2 + # This runner has no font catalogue, and it should not have one: a + # component library that needs a GUI stack installed to be tested is + # a component library nobody can test. With no fonts every glyph + # shapes to nothing, so anything sized by its text lays out flat and + # every paint assertion fails for a reason that says nothing about + # the component. The headless profile asks the same questions about + # layout instead; the full profile keeps the visual half and runs + # where there are fonts. See `PROFILES` in generate-checks.ts. + QA_PROFILE: headless run: | bun run qa:checks bun run qa:entries - git diff --exit-code -- tests/ps-qa tests/qa-harness/entries + git diff --exit-code -- tests/ps-qa tests/ps-qa-headless tests/qa-harness/entries bun run qa:build - zsh tests/qa-harness/run-all.sh + bash tests/qa-harness/run-all.sh # Fails when docs/api-contract.md and the shipped types disagree, in # either direction. Never runs with --write: regenerating the document on diff --git a/docs/api-contract.md b/docs/api-contract.md index 085e0b37..0353b649 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -11,747 +11,1925 @@ invisible for a day behind a doc that looked correct the whole time. When an API change is intentional, run `bun run check:api -- --write`, read the diff, and commit it. The diff is the review. -185 components. An empty list means the component adds nothing beyond +186 components. An empty list means the component adds nothing beyond HTML attributes and `UIBaseProps`; that is an assertion, not a gap. --- ### Accordion -`children defaultValue disabled hideSeparator onValueChange selectionMode state value variant` +```ts +children?: JSX.Element +defaultValue?: AccordionValue +disabled?: boolean +hideSeparator?: boolean +onValueChange?: (value: string[]) => void +selectionMode?: AccordionSelectionMode +state?: State +value?: AccordionValue +variant?: AccordionVariant +``` ### AccordionContent -`children keepMounted` +```ts +children?: JSX.Element +keepMounted?: boolean +``` ### AccordionIndicator -`children` +```ts +children?: JSX.Element +``` ### AccordionItem -`children disabled state value` +```ts +children?: JSX.Element +disabled?: boolean +state?: State +value?: string +``` ### AccordionTrigger -`children indicator showIndicator` +```ts +children?: JSX.Element +indicator?: JSX.Element +showIndicator?: boolean +``` ### Address -`copiedLabel copyLabel copyable explorerLabel explorerUrl font lead name onCopy size state tail truncate value` +```ts +copiedLabel?: JSX.Element +copyLabel?: JSX.Element +copyable?: boolean +explorerLabel?: JSX.Element +explorerUrl?: string +font?: "mono" | "inherit" +lead?: number +name?: JSX.Element +onCopy?: (value: string) => void +size?: Size +state?: State +tail?: number +truncate?: AddressTruncate +value: string +``` ### Alert -`children dismissLabel flavor icon onDismiss placement title variant` +```ts +children: JSX.Element +dismissLabel?: string +flavor?: Flavor +icon?: JSX.Element | false +onDismiss?: () => void +placement?: AlertPlacement +title?: JSX.Element +variant?: Variant +``` ### AuthCard -`brandingSlot children description footer title` +```ts +brandingSlot?: JSX.Element +children: JSX.Element +description?: JSX.Element +footer?: JSX.Element +title?: JSX.Element +``` ### AuthFieldGroup -`children gap` +```ts +children: JSX.Element +gap?: AuthFieldGroupGap +``` ### AuthFooterLinks -`align items` +```ts +align?: AuthFooterLinksAlign +items: AuthFooterLinkItem[] +``` ### AuthMessage -`flavor message` +```ts +flavor?: Flavor +message?: JSX.Element | string | null +``` ### AuthPoweredBy -`align href label logo variant` +```ts +align?: AuthPoweredByAlign +href?: string +label?: string +logo?: JSX.Element +variant?: AuthPoweredByVariant +``` ### AuthSubmitButton -`children flavor size state type variant width` +```ts +children: JSX.Element +flavor?: Flavor +size?: Size +state?: State +type?: "button" | "submit" | "reset" +variant?: Variant +width?: Width +``` ### Avatar -`children flavor size variant` +```ts +children: JSX.Element +flavor?: Flavor +size?: AvatarSize +variant?: AvatarVariant +``` ### AvatarFallback -`children delayMs` +```ts +children?: JSX.Element +delayMs?: number +``` ### AvatarImage -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### Badge -`children class flavor placement size state variant` +```ts +children?: JSX.Element +class?: string +flavor?: Flavor +placement?: BadgePlacement +size?: BadgeSize +state?: State +variant?: Extract +``` ### Breadcrumb -`children separator` +```ts +children: JSX.Element +separator?: JSX.Element +``` ### BreadcrumbItem -`children href isCurrent` +```ts +children: JSX.Element +href?: string +isCurrent?: boolean +``` ### Button -`children flavor href radius rel size state target type variant width` +```ts +children?: JSX.Element +flavor?: Flavor +href?: string +radius?: Radius +rel?: string +size?: Size +state?: State +target?: JSX.AnchorHTMLAttributes["target"] +type?: "button" | "submit" | "reset" +variant?: Variant +width?: Width | "square" +``` ### ButtonGroup -`children fullWidth orientation size state variant` +```ts +children?: JSX.Element +fullWidth?: boolean +orientation?: ButtonGroupOrientation +size?: Size +state?: State +variant?: Variant +``` ### ButtonGroupSeparator -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### Calendar -`defaultValue disabled isDateUnavailable locale maxValue minValue onChange onDayHover onDaySelect rangeEnd rangePreview rangeStart selectionMode showOutsideDays state value weekdayFormat` +```ts +defaultValue?: Date +disabled?: boolean +isDateUnavailable?: (date: Date) => boolean +locale?: string +maxValue?: Date +minValue?: Date +onChange?: (value: Date) => void +onDayHover?: CalendarDayHoverHandler +onDaySelect?: CalendarDaySelectHandler +rangeEnd?: Date +rangePreview?: Date +rangeStart?: Date +selectionMode?: CalendarSelectionMode +showOutsideDays?: boolean +state?: State +value?: Date +weekdayFormat?: CalendarWeekdayFormat +``` ### Card -`children elevation flavor footer header isInteractive material padding radius state variant` +```ts +children: JSX.Element +elevation?: CardElevation +flavor?: Flavor +footer?: JSX.Element +header?: JSX.Element +isInteractive?: boolean +material?: Material +padding?: Space +radius?: Radius +state?: CardState +variant?: Variant +``` ### CardBody -`children` +```ts +children: JSX.Element +``` ### CardFooter -`children` +```ts +children: JSX.Element +``` ### CardHeader -`children` +```ts +children: JSX.Element +``` ### CardRoot -`children elevation flavor footer header isInteractive material padding radius state variant` +```ts +children: JSX.Element +elevation?: CardElevation +flavor?: Flavor +footer?: JSX.Element +header?: JSX.Element +isInteractive?: boolean +material?: Material +padding?: Space +radius?: Radius +state?: CardState +variant?: Variant +``` ### ChatBubble -`end` +```ts +end?: boolean +``` ### Checkbox -`children defaultChecked description indeterminate isIndeterminate issues state variant` +```ts +children?: JSX.Element +defaultChecked?: boolean +description?: JSX.Element +indeterminate?: boolean +isIndeterminate?: boolean +issues?: Issue[] +onChange?: (checked: boolean) => void +onNativeChange?: JSX.EventHandlerUnion +state?: State +variant?: CheckboxVariant +``` ### CheckboxGroup -`children defaultValue disabled issues name onChange state value variant` +```ts +children?: JSX.Element | ((values: string[]) => JSX.Element) +defaultValue?: string[] +disabled?: boolean +issues?: Issue[] +name?: string +onChange?: (value: string[]) => void +state?: State +value?: string[] +variant?: CheckboxGroupVariant +``` ### Chip -`children class endIcon flavor onRemove removeButtonLabel size startIcon state variant` +```ts +children?: JSX.Element +class?: string +endIcon?: JSX.Element +flavor?: Flavor +onRemove?: () => void +removeButtonLabel?: string +size?: ChipSize +startIcon?: JSX.Element +state?: State +variant?: ChipVariant +``` ### CloseButton -`endIcon isPending startIcon state variant` +```ts +endIcon?: JSX.Element +isPending?: boolean +startIcon?: JSX.Element +state?: State +variant?: CloseButtonVariant +``` ### Collapsible -`children defaultOpen disabled id onOpenChange open state` +```ts +children?: JSX.Element +defaultOpen?: boolean +disabled?: boolean +id?: string +onOpenChange?: (open: boolean) => void +open?: boolean +state?: State +``` ### ColorArea -`onChange state value` +```ts +onChange?: (value: ColorAreaValue) => void +state?: State +value?: ColorAreaValue +``` ### ColorField -`defaultValue format fullWidth onChange state value` +```ts +defaultValue?: string +format?: ColorFieldFormat +fullWidth?: boolean +onChange?: (value: string) => void +state?: State +value?: string +``` ### ColorPicker -`children defaultValue onChange state value` +```ts +children?: JSX.Element +defaultValue?: string +onChange?: (value: string) => void +state?: State +value?: string +``` ### ColorSlider -`defaultValue onChange state type value` +```ts +defaultValue?: number +onChange?: (value: number) => void +state?: State +type?: ColorSliderType +value?: number +``` ### ColorSwatch -`color colorName isSelected onChange onSelect shape size state` +```ts +color: string +colorName?: string +isSelected?: boolean +onChange?: (color: string) => void +onSelect?: (color: string) => void +shape?: ColorSwatchShape +size?: ColorSwatchSize +state?: State +``` ### ColorSwatchPicker -`children defaultValue onChange state value` +```ts +children: JSX.Element +defaultValue?: string +onChange?: (value: string) => void +state?: State +value?: string +``` ### ColorWheel -`aria-label class isDisabled mode onChange palette value wheelClass` +```ts +aria-label?: string +class?: string +isDisabled?: boolean +mode?: ColorWheelFlowerMode +onChange: (value: string) => void +palette?: readonly string[] +value: string +wheelClass?: string +``` ### ColorWheelFlower -`class id mode palette` +```ts +class?: string +id?: string +mode?: ColorWheelFlowerMode +palette?: readonly string[] +``` ### ComboBox -`allowsCustomValue children defaultFilter defaultInputValue defaultOpen defaultSelectedKey disabled endIcon fullWidth inputValue issues itemDisabled itemKey itemTextValue items menuTrigger name onInputChange onOpenChange onSelectionChange open placeholder required selectedKey startIcon state variant` +```ts +allowsCustomValue?: boolean +children?: JSX.Element +defaultFilter?: (textValue: string, inputValue: string) => boolean +defaultInputValue?: string +defaultOpen?: boolean +defaultSelectedKey?: ComboBoxKey | null +disabled?: boolean +endIcon?: JSX.Element +fullWidth?: boolean +inputValue?: string +issues?: Issue[] +itemDisabled?: (item: T, index: number) => boolean +itemKey?: (item: T, index: number) => ComboBoxKey +itemTextValue?: (item: T, index: number) => string +items?: readonly T[] +menuTrigger?: ComboBoxMenuTrigger +name?: string +onInputChange?: (value: string) => void +onOpenChange?: (open: boolean) => void +onSelectionChange?: (key: string | null) => void +open?: boolean +placeholder?: string +required?: boolean +selectedKey?: ComboBoxKey | null +startIcon?: JSX.Element +state?: State +variant?: ComboBoxVariant +``` ### ComboBoxInput -`onInput` +```ts +onInput?: JSX.EventHandlerUnion +``` ### ComboBoxInputGroup -`children` +```ts +children?: JSX.Element +``` ### ComboBoxList -`children endIcon renderEmpty` +```ts +children?: JSX.Element | ((item: ComboBoxListRenderItem) => JSX.Element) +endIcon?: JSX.Element +renderEmpty?: () => JSX.Element +``` ### ComboBoxPopover -`children` +```ts +children?: JSX.Element +``` ### ComboBoxTrigger -`endIcon startIcon` +```ts +endIcon?: JSX.Element +startIcon?: JSX.Element +``` ### ComplexColorWheel -`action adjustments adjustmentsClass layout material` +```ts +action?: JSX.Element +adjustments: readonly ColorWheelAdjustment[] +adjustmentsClass?: string +layout?: "auto" | "beside" | "stacked" +material?: CardMaterial +``` ### Composer -`autofocus defaultValue hint issues lead maxRows minRows name onChange onHeightChange onSubmit placeholder radius size state submitLabel submitOnEnter trail value variant viewportHeight` +```ts +autofocus?: boolean +defaultValue?: string +hint?: JSX.Element +issues?: Issue[] +lead?: JSX.Element +maxRows?: number +minRows?: number +name?: string +onChange?: (value: string, reason?: ChangeReason) => void +onHeightChange?: (height: number) => void +onSubmit?: (value: string) => void +placeholder?: string +radius?: Radius +size?: Size +state?: State +submitLabel?: JSX.Element +submitOnEnter?: boolean +trail?: JSX.Element +value?: string +variant?: Variant +viewportHeight?: number +``` + +### ConnectionSettings + +```ts +children?: JSX.Element +endpoints: readonly ConnectionSettingsEndpointLabel[] +labels: ConnectionSettingsLabels +onResetDone?: () => void +onSaveFailed?: (error: unknown) => void +onSaved?: () => void +showAppPublicId?: boolean +store: ConnectionSettingsStore +``` ### CookieConsent -`analytics marketing onConsentChange storageKeys texts type` +```ts +analytics: boolean +marketing: boolean +onConsentChange?: (payload: { type: ConsentType; analytics: boolean; marketing: boolean; }) => void +storageKeys?: CookieConsentStorageKeys +texts?: CookieConsentTexts +type: ConsentType +``` ### DataGrid -`borders caption empty flavor interactive model onPageChange onSelectionChange onSortChange renderExpanded size sticky striping width` +```ts +borders?: DataGridBorders +caption?: JSX.Element +empty?: JSX.Element +flavor?: Flavor +interactive?: boolean +model: DataGridModel +onPageChange?: (page: number) => void +onSelectionChange?: (ids: ReadonlySet) => void +onSortChange?: (sort: DataGridSort | null) => void +renderExpanded?: (row: Row) => JSX.Element +size?: Size +sticky?: DataGridSticky +striping?: DataGridStriping +width?: Width +``` ### DateField -`children defaultValue disabled fullWidth issues name onBlur onChange required state value variant` +```ts +children?: JSX.Element | ((props: DateFieldRenderProps) => JSX.Element) +defaultValue?: string +disabled?: boolean +fullWidth?: boolean +issues?: Issue[] +name?: string +onBlur?: JSX.EventHandlerUnion +onChange?: (value: string) => void +required?: boolean +state?: State +value?: string +variant?: DateFieldVariant +``` ### DateFieldGroup -`children` +```ts +children?: JSX.Element | ((props: DateFieldRenderProps) => JSX.Element) +``` ### DateFieldInput -`onInput` +```ts +onInput?: JSX.EventHandlerUnion +``` ### DateFieldInputContainer -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### DateFieldPrefix -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### DateFieldSegment -`segment` +```ts +segment?: DateFieldSegmentValue +``` ### DateFieldSuffix -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### DatePicker -`defaultOpen defaultValue disabled isDateUnavailable locale maxValue minValue name onChange onOpenChange open placeholder state value weekdayFormat` +```ts +defaultOpen?: boolean +defaultValue?: Date +disabled?: boolean +isDateUnavailable?: (date: Date) => boolean +locale?: string +maxValue?: Date +minValue?: Date +name?: string +onChange?: (value: Date) => void +onOpenChange?: (isOpen: boolean) => void +open?: boolean +placeholder?: string +state?: State +value?: Date +weekdayFormat?: CalendarWeekdayFormat +``` ### DateRangePicker -`defaultOpen defaultValue disabled endName endPlaceholder isDateUnavailable locale maxValue minValue onChange onOpenChange open startName startPlaceholder state value weekdayFormat` +```ts +defaultOpen?: boolean +defaultValue?: DateRangeValue +disabled?: boolean +endName?: string +endPlaceholder?: string +isDateUnavailable?: (date: Date) => boolean +locale?: string +maxValue?: Date +minValue?: Date +onChange?: (value: DateRangeValue) => void +onOpenChange?: (isOpen: boolean) => void +open?: boolean +startName?: string +startPlaceholder?: string +state?: State +value?: DateRangeValue +weekdayFormat?: CalendarWeekdayFormat +``` ### Dialog -`backdrop children defaultOpen isDismissable onOpenChange open placement scrollBehavior shouldCloseOnBackdropClick shouldCloseOnEsc size` +```ts +backdrop?: DialogBackdropVariant +children: JSX.Element +defaultOpen?: boolean +isDismissable?: boolean +onOpenChange?: (isOpen: boolean) => void +open?: boolean +placement?: DialogPlacement +scrollBehavior?: DialogScrollBehavior +shouldCloseOnBackdropClick?: boolean +shouldCloseOnEsc?: boolean +size?: DialogSize +``` ### DialogBackdrop -`children isDismissable shouldCloseOnBackdropClick variant` +```ts +children: JSX.Element +isDismissable?: boolean +shouldCloseOnBackdropClick?: boolean +variant?: DialogBackdropVariant +``` ### DialogBody -`children` +```ts +children: JSX.Element +``` ### DialogCloseTrigger -`children` +```ts +children?: JSX.Element +``` ### DialogContent -`backdrop children isDismissable material placement scrollBehavior shouldCloseOnBackdropClick size` +```ts +backdrop?: DialogBackdropVariant +children: JSX.Element +isDismissable?: boolean +material?: Material +placement?: DialogPlacement +scrollBehavior?: DialogScrollBehavior +shouldCloseOnBackdropClick?: boolean +size?: DialogSize +``` ### DialogFooter -`children` +```ts +children: JSX.Element +``` ### DialogHeader -`children` +```ts +children: JSX.Element +``` ### DialogHeading -`children` +```ts +children: JSX.Element +``` ### DialogIcon -`children` +```ts +children: JSX.Element +``` ### DialogTrigger -`children` +```ts +children: JSX.Element +``` ### Dock -`baseSize desktopClass gap hoverIconSize hoverSize iconSize itemClass items magnify magnifyRange mobileClass mobileMode mobilePopupDirection mobileToggleIcon nudge orientation showContainer showDesktop showMobile springDamping springMass springStiffness tooltipClass tooltipDirection` +```ts +baseSize?: number +desktopClass?: string +gap?: number +hoverIconSize?: number +hoverSize?: number +iconSize?: number +itemClass?: string +items: DockItem[] +magnify?: boolean +magnifyRange?: number +mobileClass?: string +mobileMode?: "burger" | "dock" +mobilePopupDirection?: DockDirection +mobileToggleIcon?: JSX.Element +nudge?: number +orientation?: "horizontal" | "vertical" +showContainer?: boolean +showDesktop?: boolean +showMobile?: boolean +springDamping?: number +springMass?: number +springStiffness?: number +tooltipClass?: string +tooltipDirection?: DockDirection +``` ### Drawer -`backdrop children defaultOpen isDismissable onOpenChange open placement restoreFocus scrollBehavior shouldCloseOnBackdropClick shouldCloseOnEsc size trapFocus` +```ts +backdrop?: DrawerBackdropVariant +children: JSX.Element +defaultOpen?: boolean +isDismissable?: boolean +onOpenChange?: (isOpen: boolean) => void +open?: boolean +placement?: DrawerPlacement +restoreFocus?: boolean +scrollBehavior?: DrawerScrollBehavior +shouldCloseOnBackdropClick?: boolean +shouldCloseOnEsc?: boolean +size?: DrawerSize +trapFocus?: boolean +``` ### DrawerBackdrop -`children isDismissable shouldCloseOnBackdropClick variant` +```ts +children: JSX.Element +isDismissable?: boolean +shouldCloseOnBackdropClick?: boolean +variant?: DrawerBackdropVariant +``` ### DrawerBody -`children id` +```ts +children: JSX.Element +id?: string +``` ### DrawerClose -`children` +```ts +children: JSX.Element +``` ### DrawerCloseTrigger -`children endIcon startIcon` +```ts +children?: JSX.Element +endIcon?: JSX.Element +startIcon?: JSX.Element +``` ### DrawerContent -`children material placement scrollBehavior` +```ts +children: JSX.Element +material?: Material +placement?: DrawerPlacement +scrollBehavior?: DrawerScrollBehavior +``` ### DrawerDialog -`bg borderColor borderWidth children maxWidth padding side size width` +```ts +bg?: string +borderColor?: string +borderWidth?: string +children: JSX.Element +maxWidth?: string +padding?: string +side?: DrawerDialogSide +size?: DrawerSize +width?: string +``` ### DrawerFooter -`children` +```ts +children: JSX.Element +``` ### DrawerHandle -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### DrawerHeader -`children` +```ts +children: JSX.Element +``` ### DrawerHeading -`children id` +```ts +children: JSX.Element +id?: string +``` ### DrawerTrigger -`children` +```ts +children: JSX.Element +``` ### Dropdown -`autoFlip children dataTheme defaultOpen disabled onOpenChange open placement` +```ts +autoFlip?: boolean +children: JSX.Element +dataTheme?: string +defaultOpen?: boolean +disabled?: boolean +onOpenChange?: (open: boolean) => void +open?: boolean +placement?: DropdownPlacement +``` ### Empty -`children` +```ts +children: JSX.Element +``` ### FieldGroup -`children` +```ts +children?: JSX.Element +``` ### Fieldset -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### FieldsetActions -`children` +```ts +children?: JSX.Element +``` ### FieldsetLegend -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### FirefoxPWABanner -`extensionUrl onDismiss onInstall storageKey texts` +```ts +extensionUrl?: string +onDismiss?: () => void +onInstall?: () => void +storageKey?: string +texts?: FirefoxPWABannerTexts +``` ### Flex -`align as basis direction gap gapX gapY grow height justify minHeight minWidth paddingBlock paddingInline shrink width wrap` +```ts +align?: ResponsiveProp<"start" | "center" | "end" | "stretch" | "baseline"> +as?: keyof JSX.IntrinsicElements +basis?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +direction?: ResponsiveProp<"row" | "col" | "row-reverse" | "col-reverse"> +gap?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +gapX?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +gapY?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +grow?: ResponsiveProp +height?: ResponsiveProp<"full"> +justify?: ResponsiveProp<"start" | "center" | "end" | "between" | "around" | "evenly"> +minHeight?: ResponsiveProp<"zero"> +minWidth?: ResponsiveProp<"zero"> +paddingBlock?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +paddingInline?: ResponsiveProp<"none" | "sm" | "md" | "lg" | "xl"> +shrink?: ResponsiveProp +width?: ResponsiveProp<"full"> +wrap?: ResponsiveProp<"wrap" | "nowrap" | "wrap-reverse"> +``` ### FlexGrid -`autoLoad children class count empty fromEnd more pageSize remaining reveal rows` +```ts +autoLoad?: boolean +children: (row: T, index: () => number) => JSX.Element +class?: string +count: number +empty?: JSX.Element +fromEnd?: boolean +more?: (props: { count: number; remaining: number; reveal: () => void; }) => JSX.Element +pageSize?: number +remaining: number +reveal: () => void +rows: readonly T[] +``` ### Footer -`center children horizontal vertical` +```ts +center?: boolean +children?: JSX.Element +horizontal?: boolean +vertical?: boolean +``` ### Form -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### FormField -`class form inputProps label name` +```ts +class?: string +form?: AnyFormApi +inputProps?: Omit +label?: JSX.Element +name: string +``` ### FormSubmitButton -`children flavor form href radius rel size state target variant width` +```ts +children?: JSX.Element +flavor?: Flavor +form?: AnyFormApi +href?: string +radius?: Radius +rel?: string +size?: Size +state?: State +target?: JSX.AnchorHTMLAttributes["target"] +variant?: Variant +width?: Width | "square" +``` ### GlowCard -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### Grid -`as autoCols autoRows cols flow gap rows` +```ts +as?: keyof JSX.IntrinsicElements +autoCols?: ResponsiveProp +autoRows?: ResponsiveProp +cols?: ResponsiveProp +flow?: ResponsiveProp +gap?: ResponsiveProp +rows?: ResponsiveProp +``` ### Header -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### I18nProvider -`children i18n` +```ts +children: JSX.Element +i18n: I18nStore +``` ### Icon -`flavor height src width` +```ts +flavor?: Flavor +height?: number +src?: string | JSX.Element +width?: number +``` ### ImmersiveLanding -`appVersion children cookieConfig currentPage enableScrollNavigation firefoxPWAConfig initialPage onNavigate onNavigationComplete overlay pages pwaConfig showArrows showCookieConsent showFirefoxBanner showNavigation showPWAPrompt transitionDuration` +```ts +appVersion?: string +children: JSX.Element | ((context: ImmersiveLandingContextValue) => JSX.Element) +cookieConfig?: CookieConsentProps +currentPage?: Accessor +enableScrollNavigation?: boolean +firefoxPWAConfig?: FirefoxPWABannerProps +initialPage?: string +onNavigate?: (fromPage: string, toPage: string) => void +onNavigationComplete?: (page: string) => void +overlay?: JSX.Element | ((context: ImmersiveLandingContextValue) => JSX.Element) +pages: readonly string[] +pwaConfig?: PWAInstallPromptProps +showArrows?: boolean +showCookieConsent?: boolean +showFirefoxBanner?: boolean +showNavigation?: boolean +showPWAPrompt?: boolean +transitionDuration?: number +``` ### InlineEdit -`children disabled fieldClass fullWidth label onCommit trigger value` +```ts +children?: JSX.Element +disabled?: boolean +fieldClass?: string +fullWidth?: boolean +label?: string +onCommit?: (value: string) => void | Promise +trigger?: JSX.Element +value: string +``` ### Input -`errorMessage helperText id label` +```ts +errorMessage?: JSX.Element +helperText?: JSX.Element +id?: string +label?: JSX.Element +``` ### InputOTP -`autoFocus children defaultValue disabled inputClassName inputmode issues maxLength name onChange onComplete pattern state value variant` +```ts +autoFocus?: boolean +children?: JSX.Element +defaultValue?: string +disabled?: boolean +inputClassName?: string +inputmode?: JSX.InputHTMLAttributes["inputmode"] +issues?: Issue[] +maxLength?: number +name?: string +onChange?: (value: string) => void +onComplete?: (value: string) => void +pattern?: string +state?: State +value?: string +variant?: InputOTPVariant +``` ### InputOTPGroup -`children` +```ts +children?: JSX.Element +``` ### InputOTPSeparator -`children` +```ts +children?: JSX.Element +``` ### InputOTPSlot -`index` +```ts +index: number +``` ### Join -`horizontal responsive vertical` +```ts +horizontal?: boolean +responsive?: boolean +vertical?: boolean +``` ### Kbd -`children variant` +```ts +children?: JSX.Element +variant?: KbdVariant +``` ### KbdAbbr -`keyValue` +```ts +keyValue: KbdKey +``` ### KbdContent -`children` +```ts +children?: JSX.Element +``` ### Label -`for htmlFor issues required state` +```ts +for?: string +htmlFor?: string +issues?: Issue[] +required?: boolean +state?: State +``` ### LanguageSwitcher -`align aria-label currentLanguageLabel i18n id loadingLabel onLanguageChange optionsLabel` +```ts +align?: DropdownAlign +aria-label?: string +currentLanguageLabel?: string +i18n: I18nStore +id?: string +loadingLabel?: string +onLanguageChange?: (lang: string) => void +optionsLabel?: string +``` ### Link -`isExternal state underline variant` +```ts +isExternal?: boolean +state?: State +underline?: LinkUnderline +variant?: LinkVariant +``` ### LinkIcon -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### ListBox -`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items onAction onSelectionChange renderEmpty selectedKeys selectionMode state variant` +```ts +children?: JSX.Element | ((item: T) => JSX.Element) +defaultSelectedKeys?: Iterable +disabled?: boolean +disabledKeys?: Iterable +disallowEmptySelection?: boolean +items?: readonly T[] +onAction?: (key: string) => void +onSelectionChange?: (keys: Set) => void +renderEmpty?: () => JSX.Element +selectedKeys?: Iterable +selectionMode?: ListBoxSelectionMode +state?: State +variant?: ListBoxVariant +``` ### ListBoxItem -`children disabled id state textValue variant` +```ts +children?: JSX.Element | ((props: ListBoxItemRenderProps) => JSX.Element) +disabled?: boolean +id?: string | number +state?: State +textValue?: string +variant?: ListBoxVariant +``` ### ListBoxItemIndicator -`children` +```ts +children?: JSX.Element | ((props: ListBoxItemRenderProps) => JSX.Element) +``` ### ListBoxSection -`children title` +```ts +children?: JSX.Element +title?: JSX.Element +``` ### LiveChatBubble -`aria-label autoScrollBehavior autoScrollOnNewMessage children onClose onOpen panelProps position stickToBottomThreshold unreadCount` +```ts +aria-label?: string +autoScrollBehavior?: "instant" | "smooth" +autoScrollOnNewMessage?: boolean +children?: JSX.Element +onClose?: () => void +onOpen?: () => void +panelProps?: Omit +position?: "bottom-right" | "bottom-left" +stickToBottomThreshold?: number +unreadCount?: number +``` ### LiveChatPanel -`autoScrollBehavior autoScrollOnNewMessage closeLabel emptyMessage isSending messages mockMode onClose onSendMessage placeholder sendLabel stickToBottomThreshold title` +```ts +autoScrollBehavior?: "instant" | "smooth" +autoScrollOnNewMessage?: boolean +closeLabel?: string +emptyMessage?: string +isSending?: boolean +messages?: ChatMessage[] +mockMode?: boolean +onClose: () => void +onSendMessage?: (payload: SendMessagePayload) => Promise +placeholder?: string +sendLabel?: string +stickToBottomThreshold?: number +title?: string +``` ### Menu -`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items material onAction onSelectionChange renderEmpty selectedKeys selectionMode state` +```ts +children?: JSX.Element | ((item: T) => JSX.Element) +defaultSelectedKeys?: Iterable +disabled?: boolean +disabledKeys?: Iterable +disallowEmptySelection?: boolean +items?: readonly T[] +material?: Material +onAction?: (key: string) => void +onSelectionChange?: (keys: Set) => void +renderEmpty?: () => JSX.Element +selectedKeys?: Iterable +selectionMode?: MenuSelectionMode +state?: State +``` ### MenuItem -`children disabled hasSubmenu id onAction state textValue variant` +```ts +children?: JSX.Element | ((props: MenuItemRenderProps) => JSX.Element) +disabled?: boolean +hasSubmenu?: boolean +id?: string | number +onAction?: (key: string) => void +state?: State +textValue?: string +variant?: MenuItemVariant +``` ### MenuItemIndicator -`children type` +```ts +children?: JSX.Element | ((props: MenuItemRenderProps) => JSX.Element) +type?: MenuItemIndicatorType +``` ### MenuItemSubmenuIndicator -`children` +```ts +children?: JSX.Element +``` ### MenuSection -`children title` +```ts +children?: JSX.Element +title?: JSX.Element +``` ### MetalBorder -`children contentClass cornerRadius glow kind paused preset strength theme` +```ts +children?: JSX.Element +contentClass?: string +cornerRadius?: number | string +glow?: boolean +kind?: MetalBorderKind +paused?: boolean +preset?: MetalBorderPreset +strength?: number +theme?: MetalBorderTheme +``` ### Meter -`children flavor formatOptions formatValue highValue lowValue maxValue minValue optimumValue size state value` +```ts +children?: JSX.Element | ((state: MeterRenderState) => JSX.Element) +flavor?: Flavor +formatOptions?: Intl.NumberFormatOptions +formatValue?: (value: number, state: Omit) => string +highValue?: number +lowValue?: number +maxValue?: number +minValue?: number +optimumValue?: number +size?: MeterSize +state?: State +value?: number +``` ### MeterFill -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### MeterOutput -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### MeterTrack -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### Navbar -`as dataTheme material` +```ts +as?: keyof JSX.IntrinsicElements +dataTheme?: string +material?: Material +``` ### NoiseBackground -`animating backdropBlur borderRadius children containerClass gradientColors noiseIntensity noiseSrc showNoise speed` +```ts +animating?: boolean +backdropBlur?: boolean +borderRadius?: string +children?: JSX.Element +containerClass?: string +gradientColors?: string[] +noiseIntensity?: number +noiseSrc?: string +showNoise?: boolean +speed?: number +``` ### Pagination -`onChange page state total` +```ts +onChange: (page: number) => void +page: number +state?: State +total: number +``` ### PanelToggle -`aria-controls aria-label expanded id side` +```ts +aria-controls?: string +aria-label: string +expanded: boolean +id: string +side?: PanelToggleSide +``` ### PasswordField -`aria-describedby autocomplete autofocus class disabled hiddenIcon hideLabel id inputClass inputRef invalid label name onBlur onInput onVisibilityChange placeholder required showLabel startIcon value visibleIcon` +```ts +aria-describedby?: string +autocomplete?: "current-password" | "new-password" | "off" +autofocus?: boolean +class?: string +disabled?: boolean +hiddenIcon?: JSX.Element +hideLabel: string +id?: string +inputClass?: string +inputRef?: (el: HTMLInputElement) => void +invalid?: boolean +label?: JSX.Element +name?: string +onBlur?: () => void +onChange?: (value: string) => void +onVisibilityChange?: (visible: boolean) => void +placeholder?: string +required?: boolean +showLabel: string +startIcon?: JSX.Element +value?: string +visibleIcon?: JSX.Element +``` ### PasswordRequirements -`metIcon results title unmetIcon` +```ts +metIcon?: JSX.Element +results: PasswordRuleResult[] +title?: JSX.Element +unmetIcon?: JSX.Element +``` ### Popover -`anchorRect autoFlip children closeOnEscape closeOnOutsideClick defaultOpen offset onInteractOutside onOpenChange open placement` +```ts +anchorRect?: PopoverAnchor +autoFlip?: boolean +children: JSX.Element +closeOnEscape?: boolean +closeOnOutsideClick?: boolean +defaultOpen?: boolean +offset?: number +onInteractOutside?: (event: Event) => void +onOpenChange?: (open: boolean) => void +open?: boolean +placement?: PopoverPlacement +``` ### Progress -`flavor formatValue isIndeterminate label maxValue minValue showValue size state value` +```ts +flavor?: Flavor +formatValue?: (value: number) => string +isIndeterminate?: boolean +label?: string +maxValue?: number +minValue?: number +showValue?: boolean +size?: ProgressSize +state?: State +value?: number +``` ### PWAInstallPrompt -`appIcon appName onDismiss onInstall storageKey texts` +```ts +appIcon?: string +appName?: string +onDismiss?: () => void +onInstall?: () => void +storageKey?: string +texts?: PWAInstallPromptTexts +``` ### RadialProgress -`flavor formatValue isIndeterminate label maxValue minValue size state value` +```ts +flavor?: Flavor +formatValue?: (value: number) => string +isIndeterminate?: boolean +label?: string +maxValue?: number +minValue?: number +size?: RadialProgressSize +state?: State +value?: number +``` ### Radio -`children description indicator issues state` +```ts +children?: JSX.Element +description?: JSX.Element +indicator?: JSX.Element +issues?: Issue[] +onChange?: (checked: boolean) => void +onNativeChange?: JSX.EventHandlerUnion +state?: State +``` ### RadioGroup -`children defaultValue description disabled errorMessage issues label name onChange orientation state value variant` +```ts +children: JSX.Element +defaultValue?: string +description?: JSX.Element +disabled?: boolean +errorMessage?: JSX.Element +issues?: Issue[] +label?: JSX.Element +name?: string +onChange?: (value: string) => void +orientation?: RadioGroupOrientation +state?: State +value?: string +variant?: RadioGroupVariant +``` ### RangeCalendar -`defaultValue disabled isDateUnavailable locale maxValue minValue onChange onDayHover onDaySelect showOutsideDays state value weekdayFormat` +```ts +defaultValue?: RangeCalendarValue +disabled?: boolean +isDateUnavailable?: (date: Date) => boolean +locale?: string +maxValue?: Date +minValue?: Date +onChange?: (value: RangeCalendarValue) => void +onDayHover?: (date?: Date) => void +onDaySelect?: (date: Date) => void +showOutsideDays?: boolean +state?: State +value?: RangeCalendarValue +weekdayFormat?: CalendarWeekdayFormat +``` ### ScrollArea -`hideScrollBar isEnabled offset onVisibilityChange orientation size variant visibility` +```ts +hideScrollBar?: boolean +isEnabled?: boolean +offset?: number +onVisibilityChange?: (visibility: ScrollAreaVisibility) => void +orientation?: ScrollAreaOrientation +size?: number +variant?: ScrollAreaVariant +visibility?: ScrollAreaVisibility +``` ### Select -`autoFlip children defaultOpen defaultSelectedKeys defaultValue disabled fullWidth onChange onOpenChange onSelectionChange open placeholder placement selectedKeys selectionMode state value variant` +```ts +autoFlip?: boolean +children: JSX.Element +defaultOpen?: boolean +defaultSelectedKeys?: Iterable +defaultValue?: SelectValueType +disabled?: boolean +fullWidth?: boolean +onChange?: (value: string | string[] | null) => void +onOpenChange?: (open: boolean) => void +onSelectionChange?: (keys: Set) => void +open?: boolean +placeholder?: string +placement?: SelectPlacement +selectedKeys?: Iterable +selectionMode?: SelectSelectionMode +state?: State +value?: SelectValueType +variant?: SelectVariant +``` ### Separator -`orientation variant` +```ts +orientation?: SeparatorOrientation +variant?: SeparatorVariant +``` ### SizePicker -`aria-label onSizeChange storagePrefix` +```ts +aria-label?: string +onSizeChange?: (size: SizePreset) => void +storagePrefix?: string +``` ### Skeleton -`animation height lines radius shape size width` +```ts +animation?: SkeletonAnimation +height?: Size | number +lines?: number +radius?: Radius +shape?: SkeletonShape +size?: Size +width?: Width | number +``` ### Slider -`class dataTheme disabled formatValue label max min onChange onChangeEnd size step style value` +```ts +class?: string +dataTheme?: string +disabled?: boolean +formatValue?: (value: number) => string +label?: string +max?: number +min?: number +onChange: (value: number) => void +onChangeEnd?: (value: number) => void +size?: SliderSize +step?: number +style?: JSX.CSSProperties +value: number +``` ### Spinner -`flavor label shape size` +```ts +flavor?: Flavor +label?: string +shape?: SpinnerShape +size?: Size +``` ### Switch -`children defaultChecked description flavor icon size state` +```ts +children?: JSX.Element +defaultChecked?: boolean +description?: JSX.Element +flavor?: Flavor +icon?: JSX.Element +onChange?: (checked: boolean) => void +onNativeChange?: JSX.EventHandlerUnion +size?: ToggleSize +state?: State +``` ### Table -`variant` +```ts +variant?: TableVariant +``` ### TableExpandToggle -`disabled expanded label onToggle size` +```ts +disabled?: boolean +expanded: boolean +label?: string +onToggle?: () => void +size?: number +``` ### TableInlineConfirm -`cancelLabel confirmFlavor confirmLabel disabled loading onCancel onConfirm prompt` +```ts +cancelLabel: string +confirmFlavor?: InlineConfirmVariant +confirmLabel: string +disabled?: boolean +loading?: boolean +onCancel: () => void +onConfirm: () => void +prompt: string +``` ### TableMobileListView -`children empty emptyIcon emptyTitle itemClass listClass renderRow rows` +```ts +children?: (row: TRow, index: number) => JSX.Element +empty?: JSX.Element +emptyIcon?: string +emptyTitle?: string +itemClass?: string +listClass?: string +renderRow?: (row: TRow, index: number) => JSX.Element +rows: TRow[] +``` ### TableSortIcon -`ascIcon descIcon neutralIcon size state` +```ts +ascIcon?: JSX.Element +descIcon?: JSX.Element +neutralIcon?: JSX.Element +size?: number +state: SortIconState +``` ### TableVirtualSpacerRow -`colspan height` +```ts +colspan: number +height: number +``` ### Tabs -`children defaultSelectedKey onSelectionChange orientation selectedKey variant` +```ts +children: JSX.Element +defaultSelectedKey?: TabKey +onSelectionChange?: (key: TabKey) => void +orientation?: TabsOrientation +selectedKey?: TabKey +variant?: TabsVariant +``` ### Text -`children family leading size tracking transform variant weight` +```ts +children?: JSX.Element +family?: TextFamily +leading?: TextLeading +size?: TextSize +tracking?: TextTracking +transform?: TextTransform +variant?: TextVariant +weight?: TextWeight +``` ### Textarea -`disabled fullWidth issues state variant` +```ts +disabled?: boolean +fullWidth?: boolean +issues?: Issue[] +state?: State +variant?: TextareaVariant +``` ### ThemeColorPicker -`align aria-label autoFlip children onColorChange onThemeSwitch placement storagePrefix` +```ts +align?: ThemeColorPickerAlign +aria-label?: string +autoFlip?: boolean +children?: JSX.Element +onColorChange?: (hue: number | null, saturation: number) => void +onThemeSwitch?: (theme: "light" | "dark") => void +placement?: ThemeColorPickerPlacement +storagePrefix?: string +``` ### TimeField -`children defaultValue disabled fullWidth issues name onBlur onChange required state value variant` +```ts +children?: JSX.Element | ((props: TimeFieldRenderProps) => JSX.Element) +defaultValue?: string +disabled?: boolean +fullWidth?: boolean +issues?: Issue[] +name?: string +onBlur?: JSX.EventHandlerUnion +onChange?: (value: string) => void +required?: boolean +state?: State +value?: string +variant?: TimeFieldVariant +``` ### TimeFieldGroup -`children` +```ts +children?: JSX.Element | ((props: TimeFieldRenderProps) => JSX.Element) +``` ### TimeFieldInput -`onInput` +```ts +onInput?: JSX.EventHandlerUnion +``` ### TimeFieldInputContainer -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### TimeFieldPrefix -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### TimeFieldSegment -`segment` +```ts +segment?: TimeFieldSegmentValue +``` ### TimeFieldSuffix -`` +_No props beyond HTML attributes and `UIBaseProps`._ ### Toast -`actionProps children description indicator isEntering isExiting isFrontmost isHidden onClose state title variant` +```ts +actionProps?: ToastActionProps +children?: JSX.Element +description?: JSX.Element +indicator?: JSX.Element +isEntering?: boolean +isExiting?: boolean +isFrontmost?: boolean +isHidden?: boolean +onClose?: () => void +state?: State +title?: JSX.Element +variant?: ToastVariant +``` ### ToastActionButton -`children flavor href radius rel state target type width` +```ts +children?: JSX.Element +flavor?: Flavor +href?: string +radius?: Radius +rel?: string +state?: State +target?: JSX.AnchorHTMLAttributes["target"] +type?: "button" | "submit" | "reset" +width?: Width | "square" +``` ### ToastCloseButton -`isPending state variant` +```ts +isPending?: boolean +state?: State +variant?: CloseButtonVariant +``` ### ToastContent -`children` +```ts +children?: JSX.Element +``` ### ToastDescription -`children` +```ts +children?: JSX.Element +``` ### ToastIndicator -`children variant` +```ts +children?: JSX.Element +variant?: ToastVariant +``` ### ToastProvider -`children gap maxVisibleToasts placement queue renderToast scaleFactor width` +```ts +children?: JSX.Element +gap?: number +maxVisibleToasts?: number +placement?: ToastPlacement +queue?: ToastQueue | null +renderToast?: ToastRenderFn +scaleFactor?: number +width?: number | string +``` ### ToastTitle -`children` +```ts +children?: JSX.Element +``` ### Toolbar -`isAttached orientation` +```ts +isAttached?: boolean +orientation?: ToolbarOrientation +``` ### Tooltip -`autoFlip children closeDelay defaultOpen delay onOpenChange open placement showArrow sideOffset` +```ts +autoFlip?: boolean +children: JSX.Element +closeDelay?: number +defaultOpen?: boolean +delay?: number +onOpenChange?: (isOpen: boolean) => void +open?: boolean +placement?: TooltipPlacement +showArrow?: boolean +sideOffset?: number +``` ### TooltipArrow -`children` +```ts +children?: JSX.Element +``` ### TooltipContent -`children` +```ts +children: JSX.Element +``` ### TooltipTrigger -`children` +```ts +children: JSX.Element +``` ### VideoPreview -`mirror muted stream` +```ts +mirror?: boolean +muted?: boolean +stream: Accessor +``` diff --git a/docs/layouts.md b/docs/layouts.md index c417eae6..ad753521 100644 --- a/docs/layouts.md +++ b/docs/layouts.md @@ -59,8 +59,29 @@ import { pluginSolidLayoutsApplication } from "rsbuild-plugin-solid-layouts"; export default defineConfig({ plugins: [ pluginSolidLayoutsApplication({ layouts: ["@pathscale/ui"] }), - pluginBabel({ include: /\.(?:jsx|tsx|ts)$/ }), - pluginSolid(), + /* + * Babel does the Solid transform, and `@rsbuild/plugin-solid` is + * deliberately absent. + * + * It injects solid-refresh, whose `$component` wrapper calls + * `createSignal(component)` — and Solid 2 reads a function initialiser as a + * derivation, so it builds a computed that needs an owner. The symptom is an + * empty body and "Cannot read properties of undefined (reading 'spec')": + * the component is invoked with no props at all. + * + * `moduleName` is the second half: Solid 2 dropped the `solid-js/web` + * subpath, so the transform has to emit `@solidjs/web`. + */ + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + babelLoaderOptions: (config) => { + config.presets ??= []; + config.presets.push([ + "babel-preset-solid", + { moduleName: "@solidjs/web", generate: "dom" }, + ]); + }, + }), ], }); ``` diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 4ad3b465..8f2fc102 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -71,29 +71,83 @@ to a more opaque fill. ## Component conventions (consumer-facing) -- Booleans are HeroUI-style `is*`: `isDisabled`, `isOpen`, `isInvalid`, `isPending`, `isIconOnly`, `isHoverable`, `isPressable`. Native `disabled` also honored. +- Booleans are HeroUI-style `is*` where they exist: `isDisabled`, `isInvalid`, + `isIconOnly`, `isInteractive`. Native `disabled` is also honored. + + Not every `is*` name in older guidance is real. Overlays take `open`, not + `isOpen` -- `isOpen` is the *context* accessor a compound part reads, not a + prop you pass. Button has no `isPending`; a pending button is + `state="loading"`, from the shared `State` vocabulary. - Sizes and colour-ish props are **per-component, not a shared union in practice**. `ComponentSize` and `ComponentColor` are declared in `src/components/types.ts` with the full unions, but **they are not re-exported from the root barrel**, so consumers cannot - import them, and individual components narrow them. `Button` takes - `variant` (`primary | secondary | tertiary | outline | ghost | danger | danger-soft`) - and `size` (`sm | md | lg`); `Badge`, `Chip`, `Avatar`, `Spinner`, `Toggle`, `Meter` and - the progress components take `color`. Read the component's own props before assuming. + import them, and individual components narrow them. + + `Button` separates **shape** from **intent**, and the two are different props: + `variant` is `solid | soft | outline | ghost | plain`, `flavor` is + `neutral | primary | secondary | accent | destructive | success | warning | info`, + and `state` is the shared `State`. So the call to action is + `flavor="primary"`, not `variant="primary"` — this line previously listed + `primary | secondary | tertiary | outline | ghost | danger | danger-soft` as + the *variants*, which mixed the two vocabularies and named four values that do + not exist. + + `Badge`, `Chip`, `Avatar`, `Spinner`, `Toggle`, `Meter` and the progress + components take `color`. Read the component's own props before assuming. - Both `class` and `className` remain compatibility escape hatches. Prefer semantic component parameters; `solid-layouts-lint --porting --layouts @pathscale/ui` reports overrides that should move into recipes. -- Controlled/uncontrolled triples: `isOpen/defaultOpen/onOpenChange`, `value/defaultValue/onChange`, `selectedKey/defaultSelectedKey/onSelectionChange`. Event callbacks pass **values, not events**. +- Controlled/uncontrolled triples: `open/defaultOpen/onOpenChange`, `value/defaultValue/onChange`, `selectedKey/defaultSelectedKey/onSelectionChange`. Event callbacks pass **values, not events**. +- **Breaking in 3.1 — `Switch`, `Checkbox` and `PasswordField`.** The rule above + was already the documented contract, and three controls did not follow it. + `Switch.onChange` and `Checkbox.onChange` were the input's *native* `onChange` + and handed you an `Event`, while `Slider`, `RadioGroup` and `CheckboxGroup` + handed you a value under the same name; `PasswordField` delivered a `string` + from a prop called `onInput`, matching neither. Swapping one field for another + therefore changed what your handler received, silently. + + | control | was | now | + |---|---|---| + | `Switch` | `onChange(Event)` | `onChange(checked: boolean)` | + | `Checkbox` | `onChange(Event)` | `onChange(checked: boolean)` | + | `PasswordField` | `onInput(value)` | `onChange(value: string)` | + + The native event is still available on `Switch` and `Checkbox` as + `onNativeChange`, and it is still where `preventDefault()` vetoes the toggle — + which now also suppresses `onChange`. A `Checkbox` inside a `CheckboxGroup` + now fires its own `onChange` as well; previously that path reported nothing to + the box's own caller. +- **Breaking in 3.1 — `Select` rejects competing control sources.** Passing both + `value` and `selectedKeys` (or both `defaultValue` and `defaultSelectedKeys`) + throws. It used to prefer `selectedKeys` and silently ignore the other, so a + stale prop could win with nothing to say which had been dropped. +- **Breaking in 3.1 — `Card.state`.** Typed as the shared `State` + (`default | loading | error | invalid | disabled | hidden`) while the recipe + implements `info | success | warning | danger`. The two had no member in + common: every accepted value did nothing, every implemented value was a type + error. Now `CardState`, which is what it renders. A card is not a form + control; interactivity is `isInteractive`. - `Slider.onChange` reports continuous values. Optional `Slider.onChangeEnd` reports the final changed value once on pointer release, pointer cancellation, keyboard release, or blur fallback. Its visible `label` is also copied to the semantic slider's `aria-label`, because not every renderer resolves `aria-labelledby` across a visually hidden label. - `Collapsible.Content` retains closed content by default. Set `keepMounted={false}` to mount it only while expanded; the check is reactive, so it mounts and unmounts as the state changes. - `Popover` accepts `anchorRect` as a rectangle or rectangle accessor when content must be positioned without a trigger element. - Compound components: `Modal.Trigger`, `Tabs.List`, `Select.Option`, etc. (`Object.assign` statics; also exported flat: `AccordionRoot`, `AlertTitle`, …). Parts are styleable/testable via `data-slot="..."` and state attrs (`data-open`, `data-selected`, `data-invalid`). - `Tabs` does not require `ResizeObserver`. When it is unavailable, selection and keyboard behavior remain active and the indicator is measured on selection, mount, and window resize. -- No polymorphic `as` prop. +- `Flex`, `Grid` and `Navbar` take a polymorphic `as`. Nothing else does; reach + for the component that renders the element you want rather than repointing one + that does not. ```tsx - + ``` +> This example used to read `variant="primary" isPending={saving()}`, and none +> of it was real: `variant` is `solid | soft | outline | ghost | plain`, so +> `primary` is a **flavor**; there is no `isPending`, because pending is +> `state="loading"`; and the same page said there was no `as` prop while three +> components shipped one. The shortest documented path did not compile. + Typography presentation belongs on `Text` parameters rather than consumer utility classes: ```tsx @@ -134,6 +188,7 @@ the shared fallback. This works with PathScale Fonts and application-owned font - **Overlays**: Modal, Drawer, Popover, Dropdown, Menu, Toast, Disclosure(+Group), Accordion - **Data**: DataGrid (assembled, `createDataGrid` model), FlexGrid (incremental reveal, `createFlexGrid` model), Table (headless compound, bring your own model), plus primitives `useStreamingBuffer`, `useStreamingSubscription` - **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. +- **Connection settings**: ConnectionSettings (the panel) with `createConnectionSettings` (the store). See below. - **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, …). @@ -464,3 +519,111 @@ and have **Theming tokens** and the **Component inventory** link back here rathe duplicating them: those two change whenever the library changes, and a TSX copy has nothing keeping it honest. The inventory alone is ~40 lines that go stale the moment a component is added. + +## Connection settings + +Where an application points itself, and how that survives a reload. Every +property in this family ships this page: a switch, one or more backend URLs, an +app id, and a save that reconfigures the transport. Six of them had written it +separately, in two shapes and with different bugs. + +Two pieces. `createConnectionSettings` owns the state and the persistence; +`` is the panel over it. They are separate because the +transport needs the addresses at module scope, long before any settings page is +rendered. + +```tsx +import { createConnectionSettings } from "@pathscale/ui/hooks/connection"; +import { ConnectionSettings } from "@pathscale/ui/components/connection-settings"; + +// Module scope, so the transport and the page read the same instance. +export const connection = createConnectionSettings({ + storageKey: "acme.connection", // namespace it; two apps on one origin collide + endpoints: [ + { name: "api", fallback: "wss://api.acme.com" }, + { name: "auth", fallback: "wss://auth.honey.id" }, + ], + appPublicId: DEFAULT_APP_ID, + onApply: async ({ urls, appPublicId }) => { + reconfigure({ apiUrl: urls.api, authUrl: urls.auth, appPublicId }); + await reconnect(); + }, +}); + + +``` + +Read `connection.urls.api` from the transport. It resolves overrides and never +returns an empty string. + +It is **not** memoised: every read folds over the endpoints and rebuilds the +record. That is deliberate — the store is created at module scope, where a memo +sits in a detached root and can go stale rather than merely slow, and a stale +URL is worse than a rebuilt one. Reading it per request is fine at the scale +these panels have. Reading it in a loop is not: hoist it, or wrap it in a +`createMemo` inside your component, where there is an owner to keep it live. + +### What the store guarantees + +- **Per endpoint, not global.** Pointing the API at a local instance while auth + stays on production is the ordinary case. `setUseCustom` flips them all + together for a page that wants one switch; the panel does not use it, because + it would also mark endpoints the page never showed. +- **An override means a value that differs from the fallback.** An endpoint + storing today's default is not overridden, so when that default moves the + application moves with it. +- **Nothing customised means nothing stored.** At defaults the key is removed + rather than written, for the same reason. +- **Stored values are checked on read.** A key written by an older version, or + edited by hand, falls back field by field rather than throwing. A settings + page that cannot open is a settings page that cannot be corrected. An address + that does not validate is dropped along with its override, so a corrupted + key cannot activate an address nobody typed. +- **Addresses are validated before they are saved**, by `validate` on the + endpoint, defaulting to "parses as a URL". Give an endpoint its own when it + knows more: a WebSocket transport handed `http://` fails at connect time, far + from the page that could have explained it. + + The store enforces this, not just the panel: `setUrl` returns the reason it + refused a value and stores nothing, because every write there becomes an + active, persisted override. Keep an address a person is still typing as a + draft in your own state and call `setUrl` when it validates — which is what + `ConnectionSettings` does. +- **`apply()` persists first, then reconnects.** `onApply` receives the + settings that were just written, and storage already holds them when it runs, + so a callback that reloads or navigates reads back what it was handed. +- **`apply()` is awaited** and `isApplying` covers the reconnect, not just the + write. A failure propagates; the panel shows it and calls `onSaveFailed`. +- **Overlapping applies run in order.** Two saves in quick succession reconnect + in the order they were made, so the transport ends on the newer one. A queued + apply waits for the one in front; `onApply` reconfigures a transport this + store does not own, so a superseded reconnect cannot be undone after the fact + and is not started out of turn. + +### What the panel guarantees + +Everything is a draft until Save, the switch included. Opening the panel, +looking, and navigating away changes nothing. Save validates, commits, applies, +and reports a failure where the person who pressed it is looking. Reset drops +the overrides and applies the defaults. + +It renders a `
`, so Enter in a field saves, and its actions are the +library's `Button`. + +### Migrating a site off its own copy + +Replace the site's store with a thin adapter over this one rather than changing +every call site at once: + +```ts +export const connectionStore = { + get wsUrl() { return connection.urls.api; }, + get useCustomUrl() { return connection.isOverridden("api"); }, + applySettings() { return connection.apply(); }, +}; +``` diff --git a/layouts.library.json b/layouts.library.json index 7cd8d9db..31e06a69 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -196,6 +196,7 @@ ], "lint": { "baseline": "layouts.lint-baseline.json", - "warningsAsErrors": false + "// warningsAsErrors": "New legacy-template or manual-class debt fails the build. The baseline records what predates the layout migration; anything not in it is a component written today, and a warning nobody has to act on is a warning nobody reads. This was turned off once on the claim that the linter failed spuriously -- it does not: what was seen was the baseline ratchet doing its job after debt was removed, which docs/linting-and-porting.md describes and `solid-layouts-lint --update-baseline` resolves. Verified to have teeth: a manual class composition added to a migrated component fails `layouts:generate`.", + "warningsAsErrors": true } } diff --git a/layouts.lint-baseline.json b/layouts.lint-baseline.json index 0d86d0a0..3b9958be 100644 --- a/layouts.lint-baseline.json +++ b/layouts.lint-baseline.json @@ -363,8 +363,6 @@ "src/components/size-picker/SizePicker.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/slider/Slider.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", "src/components/slider/Slider.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", - "src/components/switch/Switch.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", - "src/components/switch/Switch.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/table/ExpandToggle.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", "src/components/table/ExpandToggle.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/table/InlineConfirm.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", @@ -417,8 +415,6 @@ "src/components/tabs/Tabs.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/tabs/Tabs.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/tabs/Tabs.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", - "src/components/text/Text.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", - "src/components/text/Text.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/textarea/Textarea.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", "src/components/textarea/Textarea.layout.tsx:warning:manual-classes:manual class composition belongs in the recipe", "src/components/theme-color-picker/ThemeColorPicker.layout.tsx:warning:legacy-template:legacy component-shaped Layout keeps presentation in component code", diff --git a/package.json b/package.json index a21d01f7..f2ba14e4 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "postcss-selector-parser": "^7.1.1", "rsbuild-plugin-solid-layouts": "^0.2.1", "solid-js": "next", - "solid-layouts": "^0.2.1", - "solid-layouts-oxc": "^0.2.1", + "solid-layouts": "^0.2.3", + "solid-layouts-oxc": "^0.2.3", "svgo": "^3.3.3", "tailwindcss": "^4.3.3", "typescript": "^6.0.3" diff --git a/scripts/check-api-contract.ts b/scripts/check-api-contract.ts index a1ccd38c..2b820a84 100644 --- a/scripts/check-api-contract.ts +++ b/scripts/check-api-contract.ts @@ -137,6 +137,39 @@ function declarationBody(text: string, typeName: string): string | null { return rhs; } +/** The name half of a `name?: type` record entry. */ +const propName = (entry: string) => + entry.slice(0, entry.indexOf(":")).replace(/\?$/, ""); + +/** + * The declared type starting at `from`, up to the member's terminator. + * + * A scan rather than a pattern, because the terminator depends on nesting: the + * comma in `Record` ends nothing, and neither does the one in + * `(value: string, index: number) => void`. Depth is tracked for `{}`, `()`, + * `[]` and `<>`; the `>` of `=>` is skipped, or every callback type would close + * a bracket it never opened. + * + * Whitespace is collapsed so the record does not move when a type is rewrapped + * across lines, which is the difference between a contract that flags real + * changes and one people learn to regenerate without reading. + */ +function typeTextAt(text: string, from: number): string { + let depth = 0; + let index = from; + for (; index < text.length; index += 1) { + const char = text[index]; + if (char === "{" || char === "(" || char === "[") depth += 1; + else if (char === "}" || char === ")" || char === "]") { + if (depth === 0) break; + depth -= 1; + } else if (char === "<") depth += 1; + else if (char === ">" && text[index - 1] !== "=") depth = Math.max(0, depth - 1); + else if ((char === ";" || char === ",") && depth === 0) break; + } + return text.slice(from, index).replace(/\s+/g, " ").trim(); +} + /** * The prop names a type declares, following intersections and aliases. * @@ -168,11 +201,21 @@ function propsOf( const stripped = rhs.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); const names = new Set(); - // Own members: every `key:` at any depth of this declaration's own literals. + /* + * Own members: every `key:` at any depth of this declaration's own literals, + * with the type it is declared as. + * + * The name alone was the whole record for a long time, and it cannot see the + * change that matters most about a callback: `onChange` went from handing + * over an `Event` to handing over a `boolean` on three components in this + * release, and the contract said nothing, because the name never moved. A + * consumer's handler keeps compiling and starts receiving something else. + */ for (const m of stripped.matchAll( - /(?:^|[{;\n])\s*(?:readonly\s+)?["']?([A-Za-z_$][\w$-]*)["']?\??\s*:/g, + /(?:^|[{;\n])\s*(?:readonly\s+)?["']?([A-Za-z_$][\w$-]*)["']?(\??)\s*:/g, )) { - names.add(m[1]); + const type = typeTextAt(stripped, m.index + m[0].length); + names.add(`${m[1]}${m[2]}: ${type}`); } // Referenced members: the parts of the intersection that are types elsewhere. @@ -191,7 +234,10 @@ function propsOf( for (const source of [text, ...files]) { const inherited = propsOf(source, omit[1], files, new Set(seen)); if (inherited === null) continue; - for (const prop of inherited) if (!removed.has(prop)) names.add(prop); + // `removed` names props; an entry is `name?: type`, so subtract on the + // name half only. + for (const prop of inherited) + if (!removed.has(propName(prop))) names.add(prop); break; } continue; @@ -288,6 +334,7 @@ function readDocumentedApi(): Api { if (!existsSync(DOC)) return new Map(); const api: Api = new Map(); let current: string | null = null; + let inBlock = false; for (const line of readFileSync(DOC, "utf8").split("\n")) { const heading = /^###\s+(\S+)/.exec(line); if (heading) { @@ -295,18 +342,23 @@ function readDocumentedApi(): Api { api.set(current, []); continue; } - const props = /^`([^`]*)`$/.exec(line.trim()); - if (current && props) { - api.set( - current, - props[1] - .split(/\s+/) - .map((p) => p.trim()) - .filter(Boolean) - .sort(), - ); + if (!current) continue; + if (line.trim() === "```ts") { + inBlock = true; + continue; + } + if (line.trim() === "```") { + inBlock = false; + api.set(current, (api.get(current) ?? []).sort()); current = null; + continue; } + if (inBlock && line.trim()) { + api.set(current, [...(api.get(current) ?? []), line.trim()]); + continue; + } + // A component with nothing of its own says so in prose and closes there. + if (line.trim().startsWith("_No props")) current = null; } return api; } @@ -337,7 +389,25 @@ function render(api: Api): string { "", ]; for (const [name, props] of [...api].sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`### ${name}`, "", `\`${props.join(" ")}\``, ""); + /* + * One prop per line, with its declared type. + * + * A space-separated list of names on one line was compact and could not see + * the change that matters most about a callback: `onChange` going from an + * `Event` to a `boolean` moves no name, so the contract stayed green while + * every consumer's handler started receiving something else. The type is + * part of the promise. + * + * A fenced block rather than backticked inline, because a type contains + * backticks-hostile punctuation and the diff of one prop should be one + * line rather than the whole component's list rewrapping. + */ + lines.push(`### ${name}`, ""); + if (props.length === 0) { + lines.push("_No props beyond HTML attributes and `UIBaseProps`._", ""); + continue; + } + lines.push("```ts", ...props, "```", ""); } return lines.join("\n"); } diff --git a/scripts/check-contracts.ts b/scripts/check-contracts.ts index 726ab02a..fb841bb4 100644 --- a/scripts/check-contracts.ts +++ b/scripts/check-contracts.ts @@ -14,6 +14,12 @@ const SKIP = new Set([ "props-table", "icon", "form", + // Not components, and never were: `_shared` is helper modules and CSS + // (`controlledState.ts`, `overlayPosition.ts`), `status` is a plain + // `status.ts`. They surfaced only once the source lookup started reporting a + // missing component instead of skipping silently. + "_shared", + "status", ]); function toPascalCase(kebab: string): string { @@ -84,28 +90,89 @@ for (const entry of entries) { continue; } - // Find the main source file (PascalCase.tsx) - const mainFile = `${pascal}.tsx`; - const mainPath = join(componentDir, mainFile); - if (!existsSync(mainPath)) { - // Some components may use a different casing or have multiple files - // Skip source-level checks but still check index.ts + /* + * Every authored source in the directory, not one file named after it. + * + * This looked only for `PascalCase.tsx` and `continue`d when it was missing. + * After the layout migration that file exists for none of the components, so + * every source-level rule below was skipped for all 93 of them -- and the run + * still printed "All 95 components pass contract checks". A check that cannot + * fail is worse than no check, because it is reported as coverage. + * + * Naming the file after its directory was also wrong on its own terms: + * `live-chat` holds `LiveChatBubble` and `LiveChatPanel`, `table` holds + * `ExpandToggle` and `InlineConfirm`. Those were never going to be found, and + * their siblings were never checked either. Checking every `*.layout.tsx` + * covers them and widens the rest. + */ + const sourcePaths = componentFiles + .filter( + (file) => + file.endsWith(".layout.tsx") || + (file === `${pascal}.tsx` && !file.endsWith(".generated.tsx")), + ) + .map((file) => join(componentDir, file)); + + if (sourcePaths.length === 0) { + fail(dir, "structure", "no authored component source to check", "Structure"); continue; } - const source = readFileSync(mainPath, "utf8"); + for (const mainPath of sourcePaths) { + checkSource(dir, mainPath); + } + + // Barrel rules are per directory, not per source: a component with two + // authored files has one `index.ts`, and reporting it twice is noise. const index = readFileSync(indexPath, "utf8"); + if (!index.includes("type ")) { + fail(dir, "structure", "index.ts must export the component's Props type", "Structure"); + } +} + +function checkSource(dir: string, mainPath: string) { + const source = readFileSync(mainPath, "utf8"); // --- Props rules --- - // Solid 2 replaced splitProps with omit; both separate component props from - // the attributes deliberately passed through to the rendered element. - if (!source.includes("splitProps") && !source.includes("omit(")) { + /* + * A compiled layout does both of these for you. + * + * `{...slot.root}` is the layout compiler's output: it assembles the recipe's + * classes and merges `class`, and `local` is the prop split. So a component + * built that way needs neither `omit()` nor `twMerge()`, and demanding them + * would be asking for hand-written plumbing back inside a compiled component. + * + * These two rules predate the layout migration, and once the file lookup + * above started finding `.layout.tsx` they failed 34 times across 22 + * components that are all correct. The rules were stale, not the components + * -- which is only visible because the check began running at all. + */ + const usesLayoutSlots = /\{\.\.\.slot[.[]/.test(source); + + /* + * Only components that actually accept pass-through need to separate it. + * + * The rule says "separate component props from HTML pass-through", so it only + * has meaning when there is pass-through: a closed prop set has nothing to + * split. `Slider` and `ColorWheelFlower` extend no `JSX.*Attributes` and + * spread nothing onto an element, and both were failing a rule that did not + * apply to them. + */ + const acceptsPassThrough = + /JSX\.\w*(HTML|SVG)\w*Attributes/.test(source) || + /\{\.\.\.(others|rest)\}/.test(source); + + if ( + acceptsPassThrough && + !usesLayoutSlots && + !source.includes("splitProps") && + !source.includes("omit(") + ) { fail(dir, "props", "must use omit() or splitProps() to separate component props from HTML pass-through", "Props"); } - // Must use twMerge for class merging - if (!source.includes("twMerge")) { + if (!usesLayoutSlots && !source.includes("twMerge")) { fail(dir, "props", "must use twMerge() for class merging", "Props"); } @@ -124,18 +191,20 @@ for (const entry of entries) { block += lines[j]; if (block.includes("}}")) break; } - // Skip if it contains dynamic values (template literals, function calls, ternaries, spread) - if (/\$\{|`|\.\.\.|[a-z]+\(|[?]/.test(block)) continue; + /* + * Skip anything dynamic: template literals, calls, ternaries, spreads -- + * and property access, which this missed. + * + * `style={{ "border-color": item.hex }}` is as dynamic as a call, but + * matched none of the patterns, so ColorWheelFlower was told to replace a + * runtime colour with a Tailwind class. There is no such class; the value + * is a swatch's own hex. + */ + if (/\$\{|`|\.\.\.|[a-z]+\(|[?]|\w+\.\w+/i.test(block)) continue; // Only flag purely static style objects fail(dir, "code-style", `static inline style={{}} could be a Tailwind class (line ~${from + 1})`, "Code Style"); } - // --- Barrel export rules --- - - // index.ts must export a type (props type) - if (!index.includes("type ")) { - fail(dir, "structure", "index.ts must export the component's Props type", "Structure"); - } } const tabsLayout = readFileSync(join(COMPONENTS_DIR, "tabs", "Tabs.layout.tsx"), "utf8"); diff --git a/src/components/auth-field-group/AuthFieldGroup.css b/src/components/auth-field-group/AuthFieldGroup.css index 56a6034a..b65a21ea 100644 --- a/src/components/auth-field-group/AuthFieldGroup.css +++ b/src/components/auth-field-group/AuthFieldGroup.css @@ -5,15 +5,23 @@ flex-direction: column; } + /* + * The same three gaps `Flex` names. + * + * `md` and `lg` already matched `gap-4` and `gap-6`; `sm` was 0.75rem where + * Flex's is 0.5rem. One name meaning two spacings is the kind of thing an + * application fixes with a corrective class once per form, forever -- and it + * is the same defect `--control-h-*` was added for, one axis over. + */ .auth-field-group--sm { - gap: 0.75rem; + gap: var(--gap-sm); } .auth-field-group--md { - gap: 1rem; + gap: var(--gap-md); } .auth-field-group--lg { - gap: 1.5rem; + gap: var(--gap-lg); } } diff --git a/src/components/button/Button.css b/src/components/button/Button.css index c5493cd1..df6319db 100644 --- a/src/components/button/Button.css +++ b/src/components/button/Button.css @@ -225,20 +225,20 @@ * Size * --------------------------------------------------------------------- */ .button--xs { - height: 1.75rem; + height: var(--control-h-xs); padding-inline: 0.5rem; font-size: 0.75rem; } .button--sm { - height: 2.25rem; + height: var(--control-h-sm); padding-inline: 0.75rem; } .button--md { - height: 2.5rem; + height: var(--control-h-md); padding-inline: 1rem; } .button--lg { - height: 2.75rem; + height: var(--control-h-lg); padding-inline: 1.125rem; font-size: 1rem; } diff --git a/src/components/card/Card.layout.tsx b/src/components/card/Card.layout.tsx index a867bb2d..50045cbf 100644 --- a/src/components/card/Card.layout.tsx +++ b/src/components/card/Card.layout.tsx @@ -2,7 +2,7 @@ import "../_shared/material.css"; import type { JSX } from "@solidjs/web"; import "./Card.css"; import {Show} from "solid-js"; -import type { Flavor, Material, Radius, Space, State, UIBaseProps, Variant } from "../vocabulary"; +import type { Flavor, Material, Radius, Space, UIBaseProps, Variant } from "../vocabulary"; import type { Layout } from "../../lib/layouts"; import { card, cardBody, cardFooter, cardHeader } from "./Card.recipe"; @@ -17,13 +17,33 @@ import { card, cardBody, cardFooter, cardHeader } from "./Card.recipe"; export type CardMaterial = Material; export type CardElevation = "none" | "sm" | "md" | "lg"; +/** + * The states this card actually renders. + * + * Not the shared `State`, which is what the prop was typed as. That union is + * `default | loading | error | invalid | disabled | hidden` and this recipe + * implements `info | success | warning | danger`: the two have **no member in + * common**. So every value the type permitted resolved to no class, and every + * value the recipe implements was a type error. + * + * A prop that accepts only values it ignores is worse than one that does not + * exist, because it reads as configured. Narrowed to what is implemented; + * `state="disabled"` and friends stop type-checking, and they never did + * anything, so nothing that worked stops working. + * + * A card is not a form control, which is why the shared vocabulary does not + * fit: it has no validity and nothing to disable. Behaviour lives on + * `isInteractive`. + */ +export type CardState = "info" | "success" | "warning" | "danger"; + export type CardProps = Omit, "children"> & UIBaseProps & { variant?: Variant; material?: Material; elevation?: CardElevation; flavor?: Flavor; - state?: State; + state?: CardState; padding?: Space; radius?: Radius; /** Replaces isHoverable and isPressable, which had one call site each across 330. */ diff --git a/src/components/card/index.ts b/src/components/card/index.ts index 84137823..6216f9b7 100644 --- a/src/components/card/index.ts +++ b/src/components/card/index.ts @@ -21,6 +21,7 @@ const Card = Object.assign(CardLayout, { export type { CardElevation, CardMaterial, + CardState, CardProps, CardSectionProps, } from "./Card.generated"; diff --git a/src/components/checkbox/Checkbox.layout.tsx b/src/components/checkbox/Checkbox.layout.tsx index 88c0eec5..7c0f3dbe 100644 --- a/src/components/checkbox/Checkbox.layout.tsx +++ b/src/components/checkbox/Checkbox.layout.tsx @@ -1,13 +1,19 @@ import "./Checkbox.css"; import type { JSX } from "@solidjs/web"; -import {Show, createSignal, createTrackedEffect, omit, useContext, type Component} from "solid-js"; +import { + type Component, + createSignal, + createTrackedEffect, + omit, + Show, + useContext, +} from "solid-js"; +import type { Layout } from "../../lib/layouts"; import { twMerge } from "../../lib/twMerge"; import { CheckboxGroupContext } from "../checkbox-group/context"; -import type { UIBaseProps, State, Issue } from "../vocabulary"; -import { CLASSES } from "./Checkbox.recipe"; -import type { Layout } from "../../lib/layouts"; -import { componentRecipe } from "./Checkbox.recipe"; +import type { Issue, State, UIBaseProps } from "../vocabulary"; import { resolveState } from "../vocabulary"; +import { CLASSES, componentRecipe } from "./Checkbox.recipe"; const invokeEventHandler = (handler: unknown, event: Event) => { if (typeof handler === "function") { @@ -22,7 +28,10 @@ const invokeEventHandler = (handler: unknown, event: Event) => { export type CheckboxVariant = "primary" | "secondary"; -export type CheckboxProps = Omit, "type" | "children"> & +export type CheckboxProps = Omit< + JSX.InputHTMLAttributes, + "type" | "children" | "onChange" +> & UIBaseProps & { defaultChecked?: boolean; children?: JSX.Element; @@ -32,6 +41,27 @@ export type CheckboxProps = Omit, "typ isIndeterminate?: boolean; indeterminate?: boolean; variant?: CheckboxVariant; + /** + * The new checked state. + * + * **Breaking in 3.1.** This was the input's native `onChange`, handing you + * an `Event`, while `Slider`, `RadioGroup` and `CheckboxGroup` handed you a + * value under the same name. Every control now reports its value here. + * + * Fires for a checkbox inside a `CheckboxGroup` too, reporting this box's + * own state; the group separately reports the whole selection through its + * own `onChange`. + * + * The native handler is {@link onNativeChange}. + */ + onChange?: (checked: boolean) => void; + /** + * The underlying `change` event, before the toggle is applied. + * + * `preventDefault()` here leaves the box as it was, suppresses + * {@link onChange}, and does not notify an enclosing group. + */ + onNativeChange?: JSX.EventHandlerUnion; }; const Checkbox: Layout = () => { @@ -54,26 +84,35 @@ const Checkbox: Layout = () => { "name", "disabled", "onChange", + "onNativeChange", "dataTheme", "aria-invalid", ); - const [internalSelected, setInternalSelected] = createSignal(Boolean(props.defaultChecked)); + const [internalSelected, setInternalSelected] = createSignal( + Boolean(props.defaultChecked), + ); const isControlled = () => props.checked !== undefined; - const optionValue = () => (props.value != null ? String(props.value) : undefined); + const optionValue = () => + props.value != null ? String(props.value) : undefined; const isGrouped = () => Boolean(group && optionValue() !== undefined); const isSelected = () => isGrouped() ? Boolean(group?.value().includes(optionValue() as string)) : isControlled() - ? Boolean(props.checked) - : internalSelected(); + ? Boolean(props.checked) + : internalSelected(); const isDisabled = () => - Boolean((props.state === "disabled")) || Boolean(props.disabled) || Boolean(group?.isDisabled()); + Boolean(props.state === "disabled") || + Boolean(props.disabled) || + Boolean(group?.isDisabled()); const isInvalid = () => - Boolean((resolveState(props.state, props.issues) === "invalid")) || Boolean(local["aria-invalid"]) || Boolean(group?.isInvalid()); - const isIndeterminate = () => Boolean(props.isIndeterminate) || Boolean(props.indeterminate); + Boolean(resolveState(props.state, props.issues) === "invalid") || + Boolean(local["aria-invalid"]) || + Boolean(group?.isInvalid()); + const isIndeterminate = () => + Boolean(props.isIndeterminate) || Boolean(props.indeterminate); const variant = () => props.variant ?? group?.variant() ?? "primary"; const name = () => props.name ?? group?.name(); const hasContent = () => props.children != null || props.description != null; @@ -83,29 +122,44 @@ const Checkbox: Layout = () => { inputRef.indeterminate = isIndeterminate(); }); - const handleChange: JSX.EventHandlerUnion = (event) => { - invokeEventHandler(props.onChange, event); + const handleChange: JSX.EventHandlerUnion = ( + event, + ) => { + // The event handler first, so `preventDefault()` vetoes the toggle, the + // value callback and the group notification alike. + invokeEventHandler(props.onNativeChange, event); if (event.defaultPrevented) return; if (isDisabled()) return; + const checked = event.currentTarget.checked; + if (group && optionValue() !== undefined) { - group.toggleValue(optionValue() as string, event.currentTarget.checked, event); + group.toggleValue(optionValue() as string, checked, event); + // Still reported, before returning. A checkbox in a group used to be the + // one control that told its own caller nothing: `onChange` on the box was + // the native event and this path skipped it, so a per-box handler simply + // never ran. The group's own `onChange` reports the whole selection; this + // reports the box. + props.onChange?.(checked); return; } if (!isControlled()) { - setInternalSelected(event.currentTarget.checked); + setInternalSelected(checked); } + props.onChange?.(checked); }; return (