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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
147 changes: 14 additions & 133 deletions bun.lock

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### DialogContent

`backdrop children isDismissable placement scrollBehavior shouldCloseOnBackdropClick size`
`backdrop children isDismissable material placement scrollBehavior shouldCloseOnBackdropClick size`

### DialogFooter

Expand Down Expand Up @@ -318,7 +318,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### DrawerContent

`children placement scrollBehavior`
`children material placement scrollBehavior`

### DrawerDialog

Expand Down Expand Up @@ -422,7 +422,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### InputOTP

`autoFocus children defaultValue disabled inputClassName inputMode issues maxLength name onChange onComplete pattern state value variant`
`autoFocus children defaultValue disabled inputClassName inputmode issues maxLength name onChange onComplete pattern state value variant`

### InputOTPGroup

Expand Down Expand Up @@ -494,7 +494,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### Menu

`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items onAction onSelectionChange renderEmpty selectedKeys selectionMode state`
`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items material onAction onSelectionChange renderEmpty selectedKeys selectionMode state`

### MenuItem

Expand Down Expand Up @@ -534,7 +534,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### Navbar

`as dataTheme`
`as dataTheme material`

### NoiseBackground

Expand Down Expand Up @@ -634,7 +634,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### TableVirtualSpacerRow

`colSpan height`
`colspan height`

### Tabs

Expand Down
46 changes: 40 additions & 6 deletions docs/ui-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,44 @@ Layout components require the application compiler before the normal Solid trans
## Theming

- Two themes: `light` (default when no attribute) and `dark`. Switch: `document.documentElement.setAttribute("data-theme", "dark")`.
- Tokens are CSS vars: `--color-primary(/-content)`, `--color-secondary`, `--color-accent`, `--color-neutral`, `--color-info/success/warning/error`, `--color-danger`, surfaces `--color-base-100/200/300`, `--color-base-content`, HeroUI-style `--color-default(/-foreground/-hover)`, `--color-background/foreground`, daisy short aliases `--b1/--b2/--b3/--bc`, radii `--radius-selector/-field/-box`, and a ~35-var `--glass-*` set (runtime-tweakable on `documentElement.style`).
- Tokens are CSS vars: `--color-primary(/-content)`, `--color-secondary`, `--color-accent`, `--color-neutral`, `--color-info/success/warning/error`, `--color-danger`, surfaces `--color-base-100/200/300`, `--color-base-content`, HeroUI-style `--color-default(/-foreground/-hover)`, `--color-background/foreground`, daisy short aliases `--b1/--b2/--b3/--bc`, radii `--radius-selector/-field/-box`, and the `--glass-*` set: six colours the theme owns, plus the rest derived from three numbers by `applyGlassTokens` (see [Glass](#glass)).
- `src/index.css` has a Tailwind v4 `@theme` block, so in a Tailwind v4 app `bg-primary`, `text-base-content`, `bg-base-100` etc. work.
- Any component accepts `dataTheme` prop → rendered as `data-theme` attr (scoped theming).

### Glass

`material="glass"` makes a surface out of what is behind it rather than a fill.
It is on `Card`, `Dialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu`
and `Navbar`; `solid` is the default everywhere, so nothing changes until it is
asked for.

The look is driven by the `--glass-*` custom properties, and you do not set
those by hand — `applyGlassTokens` derives all twenty-five from three numbers:

```ts
import { applyGlassTokens, GLASS_DEFAULTS, GLASS_LIMITS } from "@pathscale/ui";

applyGlassTokens({ blur: 9, refraction: 0.31, depth: 24 }, "dark");
```

- `blur` (0–50px) how far the backdrop is smeared
- `refraction` (0–0.4) how much the surface asserts its own tint, border and highlight
- `depth` (0–30) how far off the page it sits: glow, sheen, shadow

`GLASS_LIMITS` carries those ranges and `GLASS_DEFAULTS` the per-mode defaults,
so a settings panel should read both from here rather than restating them.
`resolveGlassTokens` returns the same set as an object, and `glassTokensToCss`
as a declaration block for a theme that wants them baked in.

Pass a **complete** tuning. Three of the derived tokens are read without a CSS
fallback, and an undefined custom property makes CSS drop the whole declaration
rather than fall back — so a partial set renders a surface with no background at
all rather than a plainer one.

Nested glass is flattened to one pane on purpose, and both
`prefers-reduced-transparency` and a browser without `backdrop-filter` fall back
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.
Expand Down Expand Up @@ -92,7 +126,7 @@ the shared fallback. This works with PathScale Fonts and application-owned font

## Component inventory (by family)

- **Layout/primitives**: Flex, Grid, Join, Surface, Card, GlassPanel, Separator, ScrollShadow, Skeleton, EmptyState, Footer, Header, Navbar, Toolbar, FloatingDock
- **Layout/primitives**: Flex, Grid, Join, Card, Separator, ScrollArea, Skeleton, Empty, Footer, Header, Navbar, Toolbar, Dock
- **Typography/misc**: Text, Link, Kbd, Badge, Chip, Tag/TagGroup, Avatar, Icon, Tooltip, Breadcrumbs, Pagination, Meter, ProgressBar, ProgressCircle, Spinner (alias: Loading)
- **Inputs**: Input, InputGroup, InputOTP, TextField, TextArea (and textarea), NumberField, SearchField, PasswordField (+ password-requirements/rules, `passwordRules.ts`), ColorField, Checkbox(+Group), Radio(+Group), Toggle, Slider, Select, ComboBox, ListBox, SizePicker, Form pieces (Label, Description, ErrorMessage, FieldError, Fieldset)
- **Dates**: Calendar, RangeCalendar, DatePicker, DateRangePicker (internal date engine); DateField, TimeField (separate segmented editors)
Expand All @@ -112,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 @@ -131,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 @@ -216,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
1 change: 1 addition & 0 deletions layouts.library.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"mode": "source",
"solid": 2,
"source": "src/components",
"output": "dist",
"exports": [
Expand Down
37 changes: 7 additions & 30 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pathscale/ui",
"version": "2.5.0",
"version": "2.6.0",
"author": "pathscale",
"repository": {
"type": "git",
Expand Down Expand Up @@ -73,52 +73,29 @@
"@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",
"@solidjs/h": "2.0.0-rc.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",
"cssnano": "^7.1.9",
"postcss": "^8.5.15",
"postcss-cli": "^11.0.1",
"postcss-selector-parser": "^7.1.1",
"rsbuild-plugin-solid-layouts": "^0.1.4",
"solid-js": "^1.9.14",
"solid-layouts": "^0.1.3",
"solid-layouts-oxc": "^0.1.7",
"rsbuild-plugin-solid-layouts": "0.2.1",
"solid-js": "2.0.0-rc.0",
"solid-layouts": "0.2.0",
"solid-layouts-oxc": "0.2.1",
"svgo": "^3.3.3",
"typescript": "^6.0.3"
},
"dependencies": {
"@solidjs/web": "2.0.0-rc.0",
"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
27 changes: 27 additions & 0 deletions scripts/check-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ if (
);
}

// --- Stale generated output ---
//
// `*.generated.tsx` is gitignored and written by `layouts:generate`, which only
// ever adds. Deleting a component therefore leaves its generated file behind,
// still importing the `.recipe` and `.layout` that went with it, and every
// build from then on fails inside a file nobody can see in `git status`.
//
// That is exactly how 2.5.0 shipped: twenty-two deleted components left
// twenty-two orphans, `bun run check` reported them as "missing index.ts
// barrel export" for components that no longer exist, and `bun run build` died
// generating declarations. Caught here it names the real problem and the fix.
for (const entry of readdirSync(COMPONENTS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = join(COMPONENTS_DIR, entry.name);
for (const file of readdirSync(dir)) {
if (!file.endsWith(".generated.tsx")) continue;
const authored = join(dir, file.replace(/\.generated\.tsx$/, ".layout.tsx"));
if (existsSync(authored)) continue;
fail(
entry.name,
"stale-generated",
`${join(dir, file)} has no .layout.tsx source; delete it (it is gitignored build output from a component that was removed)`,
"Structure",
);
}
}

// --- Report ---

if (violations.length === 0) {
Expand Down
115 changes: 115 additions & 0 deletions src/components/_shared/material.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* `material="glass"`, for every surface that can hold content.
*
* One definition rather than one per component. Card had these rules inline,
* and copying them to Dialog, Drawer, Popover, Menu, Dock and Navbar would be
* six places to update when the glass vocabulary moves — which it did once
* already, from thirty-one hand-declared properties to three numbers and
* twenty-five derived ones.
*
* A component opts in by importing this file and adding the `material` axis to
* its recipe. The classes are keyed on a data attribute rather than a
* `<component>--material-glass` class so the rules are genuinely shared: a
* per-component class name would need this block duplicated for each one, which
* is the thing being avoided.
*
* Every `--glass-*` read carries a fallback, and that is load-bearing rather
* than tidy. An undefined custom property does not fall back to its initial
* value: CSS drops the whole declaration. Three of these used to be read bare,
* so a theme that defined twenty-eight of the thirty-one glass tokens got a
* surface with no background at all rather than a plainer one. The two shipped
* themes both use `white` for the three colours, so that is what the fallbacks
* say. `src/styles/glass.ts` derives the rest from `blur`, `refraction` and
* `depth`.
*/

@layer components {
[data-material="solid"] {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

[data-material="glass"] {
background-color: color-mix(
in oklab,
var(--glass-background-color, white),
transparent calc(100% - var(--glass-background-opacity, 55%))
);
border-color: color-mix(
in oklab,
var(--glass-border-color, white),
transparent calc(100% - var(--glass-border-opacity, 12%))
);
backdrop-filter: blur(var(--glass-blur, 50px)) saturate(var(--glass-saturation, 1.2))
brightness(var(--glass-brightness, 1));
-webkit-backdrop-filter: blur(var(--glass-blur, 50px)) saturate(var(--glass-saturation, 1.2))
brightness(var(--glass-brightness, 1));
}

/*
* The inner highlight: the lit top edge that makes a pane read as a pane
* rather than as a translucent rectangle.
*
* On a pseudo-element so it cannot be overwritten by a component's own
* `box-shadow`, which is how elevation is expressed. `inherit` on the radius
* keeps it aligned to whatever corner the component chose.
*/
[data-material="glass"]::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 var(--border, 1px) 0 0
color-mix(
in oklab,
var(--glass-highlight-color, white),
transparent calc(100% - var(--glass-highlight-opacity, 18%))
);
}

/*
* Glass inside glass is one pane, not two.
*
* A backdrop filter reads the pixels behind it, so nesting one inside another
* blurs already-blurred output. That is muddy rather than deeper, and it
* costs a second render pass to get there.
*/
[data-material="glass"] [data-material="glass"] {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

/*
* No backdrop support, or a reader who asked for less transparency.
*
* Falls back to a more opaque fill rather than to a thin tint over whatever
* happens to be behind it, because the tint alone is unreadable. The
* highlight goes too: it reads as a stray line without the material.
*/
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
[data-material="glass"] {
background-color: color-mix(
in oklab,
var(--glass-background-color, white),
transparent calc(100% - var(--glass-fallback-background-opacity, 78%))
);
}
}

@media (prefers-reduced-transparency: reduce) {
[data-material="glass"] {
background-color: color-mix(
in oklab,
var(--glass-background-color, white),
transparent calc(100% - var(--glass-fallback-background-opacity, 78%))
);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

[data-material="glass"]::before {
box-shadow: none;
}
}
}
9 changes: 2 additions & 7 deletions src/components/_shared/overlayPosition.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import {
createEffect,
createSignal,
onCleanup,
type Accessor,
type JSX,
} from "solid-js";
import {createEffect, createSignal, onCleanup, type Accessor} from "solid-js";
import type { JSX } from "@solidjs/web";

export type OverlayPlacement = "top" | "bottom" | "left" | "right";
export type OverlayAlign = "start" | "center" | "end";
Expand Down
Loading
Loading