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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ The rules that hold across every component — worth two minutes before your fir
flat. Parts are styleable via `data-slot` and state attributes such as `data-open`.
- There is **no polymorphic `as` prop**.

Forms are TanStack Form plus any Standard Schema validator; Table is headless and assembled
from hooks. Both, with toasts, icons and dates, are covered in
Forms are built in and take any Standard Schema validator; DataGrid derives filtering,
sorting, pagination and selection itself. Both, with toasts, icons and dates, are covered in
**[docs/ui-usage.md](https://github.com/pathscale/ui/blob/master/docs/ui-usage.md)**.

## Requirements
Expand All @@ -137,8 +137,7 @@ line and must not be used.
The complete compiler contract, hard-error behavior, commands, and porting analyzer are
documented in [docs/layouts.md](docs/layouts.md).

Peers include `@solid-primitives/*`, `@tanstack/solid-form` and `@tanstack/solid-table`.
Two are optional and only needed for the features they back: `popmotion` (JS animation
Two peers are optional and only needed for the features they back: `popmotion` (JS animation
driver) and `@standard-schema/spec` (schema validation).

## Subpath exports
Expand Down
129 changes: 1 addition & 128 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/ui-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ the same normalized hex that it displays. Surface strength, softness and accent
remain consumer theme concerns; use the literal selected hex as their input rather than
making the wheel silently transform it.

## Forms (TanStack Form + Standard Schema)
## Forms (built in, plus Standard Schema)

```tsx
import { createForm, Form, FormField, FormSubmitButton } from "@pathscale/ui";
Expand All @@ -165,8 +165,8 @@ const form = createForm({
```

- `Form` without a `form` prop = plain styled `<form>` (`FormRoot`). With `form` = context provider + wired submit.
- Inside a `<Form>`: `useField(name)` → `{value, error, touched, invalid, handleChange, handleBlur}`. **Errors are touch-gated** — `error()` is `undefined` until the field blurs. `FormSubmitButton` disables on `!canSubmit` (not touch-gated), so the button can be disabled with no visible error.
- Escape hatch: `form._tsForm` is the raw TanStack form API (typed `any` on purpose).
- Inside a `<Form>`: `useField(name)` → `{value, error, touched, invalid, handleChange, handleBlur}`. **Errors are touch-gated** — `error()` is `undefined` until the field blurs. `FormSubmitButton` disables on `!form.isValid()` (not touch-gated), so the button can be disabled with no visible error. A failed `submit()` touches every field, so the errors it refused on all become visible at once.
- The form API is the library's own: `values()`, `getFieldValue`, `getFieldMeta`, `setFieldValue`, `validateField`, `submit()`, `isSubmitting()`, `isValid()`. There is no longer a `_tsForm` escape hatch, because there is no longer a wrapped library to escape to.
- Schema validation runs on change+blur+submit; blur errors clear immediately on change once valid.

## DataGrid (assembled)
Expand Down Expand Up @@ -250,7 +250,7 @@ const table = useTableModel({

- State-slice hooks (all controlled-or-uncontrolled): `useTableSorting`, `useTableSelection`, `useTableFiltering` (per-column popovers + `getColumnFilterProps`), `useTablePagination` (⚠️ `nextPage(max)`/`lastPage(max)` need caller-supplied max page index), `useTableExpansion`.
- Parts: TableRoot/ScrollContainer/Content/Header/Column/Body/Row/Cell/ExpandedRow/Footer/PageSize/ResizableContainer/ColumnResizer/LoadMore(+Content), plus SortIcon, ExpandToggle, InlineConfirm, MobileListView (responsive card fallback), VirtualSpacerRow.
- **Virtualization is not built in**: combine `useVirtualRows` (wraps @tanstack/solid-virtual) + `VirtualSpacerRow` yourself. See the Table section above.
- **Virtualization is not built in.** `useVirtualRows` was a thin wrapper over `@tanstack/solid-virtual` that nothing in the library used; it was removed with the rest of TanStack. `VirtualSpacerRow` is still here for a caller that brings its own windowing.

## Motion

Expand Down
25 changes: 0 additions & 25 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,7 @@
"@rsbuild/core": "^2.0.9",
"@rsbuild/plugin-solid": "^1.2.1",
"@rslib/core": "^0.22.0",
"@solid-primitives/event-listener": "^2.4.5",
"@solid-primitives/intersection-observer": "^2.2.4",
"@solid-primitives/keyboard": "^1.3.5",
"@solid-primitives/media": "^2.3.5",
"@solid-primitives/props": "^3.2.3",
"@solid-primitives/resize-observer": "^2.1.5",
"@solid-primitives/scheduled": "^1.5.3",
"@solid-primitives/scroll": "^2.1.5",
"@solid-primitives/storage": "^2.1.4",
"@solid-primitives/utils": "^6.4.0",
"@standard-schema/spec": "^1.1.0",
"@tanstack/solid-form": "^1.33.0",
"@tanstack/solid-table": "^8.21.3",
"@types/bun": "^1.3.14",
"babel-preset-solid": "^1.9.12",
"cally": "^0.8.0",
Expand All @@ -102,23 +90,10 @@
},
"dependencies": {
"clsx": "^2.1.1",
"@tanstack/solid-virtual": "^3.13.27",
"tailwind-merge": "^3.6.0"
},
"peerDependencies": {
"@solid-primitives/event-listener": "^2.3.0",
"@solid-primitives/intersection-observer": "^2.1.3",
"@solid-primitives/keyboard": "^1.2.5",
"@solid-primitives/media": "^2.2.5",
"@solid-primitives/props": "^3.1.8",
"@solid-primitives/resize-observer": "^2.0.22",
"@solid-primitives/scheduled": "^1.4.1",
"@solid-primitives/scroll": "^2.0.20",
"@solid-primitives/storage": "^2.1.1",
"@solid-primitives/utils": "^6.2.1",
"@standard-schema/spec": "^1.0.0",
"@tanstack/solid-form": "^1.29.0",
"@tanstack/solid-table": "^8.0.0",
"popmotion": "^11.0.5",
"solid-js": "^1.9",
"solid-layouts": "^0.1.3"
Expand Down
2 changes: 1 addition & 1 deletion src/components/form/Form.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const FormWithContext: Layout<typeof componentRecipe, FormWithContextProps> = ()
const handleSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent> = (e) => {
e.preventDefault();
e.stopPropagation();
local.form._tsForm.handleSubmit();
void local.form.submit();
if (typeof local.onSubmit === "function") {
local.onSubmit(e);
}
Expand Down
67 changes: 27 additions & 40 deletions src/components/form/FormField.layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Show, splitProps, type Component, type JSX } from "solid-js";
import { twMerge } from "tailwind-merge";
import type { FieldApi } from "@tanstack/solid-form";

import Input from "../input";
import type { InputFieldProps } from "../input";
Expand Down Expand Up @@ -75,49 +74,37 @@ const FormField: Layout<typeof componentRecipe, FormFieldProps> = () => {
};

const form = resolveForm();
const tsForm = form._tsForm;

return (
<tsForm.Field
name={local.name as never}
children={(field: () => FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>) => {
const errorMessage = () =>
field().state.meta.isTouched
? getFirstFieldError((field().state.meta.errors ?? []) as unknown[])
: undefined;
// Read straight off the form rather than through a render-prop child. The
// engine is ours now, so field state is just three accessors, and a wrapper
// component that exists only to hand them down would be a layer with no job.
const meta = () => form.getFieldMeta(local.name);
const errorMessage = () =>
meta().isTouched ? getFirstFieldError(meta().errors) : undefined;

return (
<div
{...{ class: twMerge("flex flex-col gap-1", local.class) }}
data-slot="form-field"
>
<Show when={local.label}>
<Label
for={local.name}
data-invalid={Boolean(errorMessage()) ? "true" : undefined}
>
{local.label}
</Label>
</Show>
return (
<div {...{ class: twMerge("flex flex-col gap-1", local.class) }} data-slot="form-field">
<Show when={local.label}>
<Label for={local.name} data-invalid={errorMessage() ? "true" : undefined}>
{local.label}
</Label>
</Show>

<Input.Field
{...local.inputProps}
id={local.name}
name={local.name}
value={String(field().state.value ?? "")}
onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => {
field().handleChange(e.currentTarget.value as never);
}}
onBlur={() => field().handleBlur()}
aria-invalid={Boolean(errorMessage()) ? true : undefined}
issues={errorMessage() ? [{ code: "invalid", message: String(errorMessage()) }] : undefined}
/>
<Input.Field
{...local.inputProps}
id={local.name}
name={local.name}
value={String(form.getFieldValue(local.name) ?? "")}
onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => {
form.setFieldValue(local.name, e.currentTarget.value);
}}
onBlur={() => form.validateField(local.name, "blur")}
aria-invalid={errorMessage() ? true : undefined}
issues={errorMessage() ? [{ code: "invalid", message: String(errorMessage()) }] : undefined}
/>

<FieldErrorMessage message={errorMessage()} />
</div>
);
}}
/>
<FieldErrorMessage message={errorMessage()} />
</div>
);
};

Expand Down
26 changes: 10 additions & 16 deletions src/components/form/FormSubmitButton.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,18 @@ const FormSubmitButton: Layout<typeof componentRecipe, FormSubmitButtonProps> =
};

const form = resolveForm();
const tsForm = form._tsForm;

// `<tsForm.Subscribe>` was a render prop whose only job was to deliver two
// booleans. Both are accessors on the form now, so the component reads them
// directly and Solid tracks them the same way.
return (
<tsForm.Subscribe
selector={(s: { canSubmit: boolean; isSubmitting: boolean }) => ({
canSubmit: s.canSubmit,
isSubmitting: s.isSubmitting,
})}
children={(state: () => { canSubmit: boolean; isSubmitting: boolean }) => (
<Button
{...others}
type="submit"
state={state().isSubmitting ? "loading" : state().canSubmit ? "default" : "disabled"}
>
{local.children}
</Button>
)}
/>
<Button
{...others}
type="submit"
state={form.isSubmitting() ? "loading" : form.isValid() ? "default" : "disabled"}
>
{local.children}
</Button>
);
};

Expand Down
30 changes: 2 additions & 28 deletions src/components/table/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,7 @@
export type { ExpandToggleProps } from "./ExpandToggle.generated";
export { default as ExpandToggle } from "./ExpandToggle.generated";
export type {
HookSortDescriptor,
HookSortDirection,
TableSelectionState,
UseAnchoredOverlayPositionOptions,
UseTableExpansionOptions,
UseTableExpansionResult,
UseTableFilteringOptions,
UseTableFilteringResult,
UseTableModelOptions,
UseTablePaginationOptions,
UseTablePaginationResult,
UseTableSelectionOptions,
UseTableSelectionResult,
UseTableSortingOptions,
UseTableSortingResult,
} from "./hooks";
export {
toSortDescriptor,
toSortingState,
useAnchoredOverlayPosition,
useTableExpansion,
useTableFiltering,
useTableModel,
useTablePagination,
useTableSelection,
useTableSorting,
} from "./hooks";
export type { UseAnchoredOverlayPositionOptions } from "./hooks";
export { useAnchoredOverlayPosition } from "./hooks";
export type {
InlineConfirmProps,
InlineConfirmVariant,
Expand Down
7 changes: 3 additions & 4 deletions src/hooks/form/FormContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import type { FormApi } from "./createForm";
// Context
// ---------------------------------------------------------------------------

// We use `any` here because the form's generic parameters are deeply nested
// TanStack types — the context just needs to hold the opaque FormApi wrapper.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// The context holds a form whose value type the provider knows and the
// consumer does not, so the stored type erases it.
export type AnyFormApi = FormApi<any>;

export const FormContext = createContext<AnyFormApi | null>(null);
Expand All @@ -26,7 +25,7 @@ export const FormContext = createContext<AnyFormApi | null>(null);
* @example
* // Inside a component rendered as a child of <Form form={form}>
* const form = useFormContext();
* const tsForm = form._tsForm;
* const value = form.getFieldValue("email");
*/
export const useFormContext = (): AnyFormApi => {
const ctx = useContext(FormContext);
Expand Down
Loading