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
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,29 @@ import {Button, Avatar} from "@offload-project/hallogen";

Higher-level, Inertia-aware building blocks:

`form-section` · `header` · `resource-clear-filters` · `resource-table` · `breadcrumb-items-render` ·
`menu-items-render` · `navbar-items-render` · `pagination-render` · `sidebar-items-render` · `svg-html-render`
`form-section` · `header-package` · `resource-clear-filters` · `resource-filters` · `resource-table` ·
`breadcrumb-items-render` · `menu-items-render` · `navbar-items-render` · `pagination-render` ·
`sidebar-items-render` · `svg-html-render`

### Shared style presets

`tailwind-variants` style definitions shared across components, importable for building custom variants:
[`tailwind-variants`](https://www.tailwind-variants.org/) style definitions shared across components,
importable for building your own variants. Available from the barrel or the `shared/<name>` subpath:

`shared/badge-styles` · `shared/button-styles` · `shared/field-styles` · `shared/toggle-styles`
| Module | Exports |
|------------------------|-----------------------------------------------------------------------|
| `shared/badge-styles` | `badgeStyles` |
| `shared/button-styles` | `buttonStyles` |
| `shared/field-styles` | `labelStyles`, `descriptionStyles`, `fieldErrorStyles`, `fieldStyles` |
| `shared/toggle-styles` | `toggleStyles` |

```tsx
import {buttonStyles} from "@offload-project/hallogen/shared/button-styles";
// or from the barrel: import { buttonStyles } from "@offload-project/hallogen";

// reuse the button's variants on a custom element
<a className={buttonStyles({variant: "primary", size: "sm"})}>Link button</a>;
```

## Hooks

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@
"types": "./dist/components/composite/resource-clear-filters.d.ts",
"import": "./dist/components/composite/resource-clear-filters.js"
},
"./resource-filters": {
"types": "./dist/components/composite/resource-filters.d.ts",
"import": "./dist/components/composite/resource-filters.js"
},
"./resource-table": {
"types": "./dist/components/composite/resource-table.d.ts",
"import": "./dist/components/composite/resource-table.js"
Expand Down
56 changes: 56 additions & 0 deletions src/components/composite/resource-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { SearchField, SearchInput } from "@offload-project/hallogen/search-field";
import { Toolbar, ToolbarLeft, ToolbarRight } from "@offload-project/hallogen/toolbar";
import type { ReactNode, SubmitEvent } from "react";
import type { Key } from "react-aria-components";
import { ResourceClearFilters } from "@/components/composite/resource-clear-filters";
import { TableColumnMenu } from "@/components/ui/table.tsx";
import type { ColumnDef } from "@/types";

interface Props {
searchLabel: string;
searchPlaceholder: string;
searchDefaultValue: string | null;
onSearchSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
/** Toggleable columns for the column-visibility menu. */
columns: ColumnDef[];
visible: Set<Key>;
onColumnsChange: (visible: Set<Key>) => void;
/** Current filter state, used to decide whether to show "Clear filters". */
filters: object;
clearUrl: string;
/** Optional filter dropdown(s) rendered between search and the column menu. */
children?: ReactNode;
}

/**
* The standard admin index filter bar: a search field, an optional filter
* dropdown slot, the column-visibility menu, and a clear-filters link.
*/
export function ResourceFilters({
searchLabel,
searchPlaceholder,
searchDefaultValue,
onSearchSubmit,
columns,
visible,
onColumnsChange,
filters,
clearUrl,
children,
}: Props) {
return (
<Toolbar className="-mx-(--container-padding)">
<ToolbarLeft />
<ToolbarRight>
<form onSubmit={onSearchSubmit}>
<SearchField aria-label={searchLabel} defaultValue={searchDefaultValue ?? ""}>
<SearchInput placeholder={searchPlaceholder} />
</SearchField>
</form>
{children}
<TableColumnMenu columns={columns} visible={visible} onChange={onColumnsChange} />
<ResourceClearFilters filters={filters} url={clearUrl} />
</ToolbarRight>
</Toolbar>
);
}
61 changes: 41 additions & 20 deletions src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CheckIcon, MinusIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
CheckboxButton,
type CheckboxButtonProps,
Expand All @@ -17,69 +18,89 @@ export function CheckboxGroup({ className, ...props }: CheckboxGroupProps) {
{...props}
data-slot="control"
className={cx(
"space-y-3 has-[[slot=description]]:not-has-[[slot=errorMessage]]:space-y-6 has-[[slot=description]]:**:data-[slot=label]:font-medium **:[[slot=description]]:block",
"space-y-3 has-[[slot=description]]:space-y-6 has-[[slot=description]]:**:data-[slot=label]:font-medium **:[[slot=description]]:block",
className,
)}
/>
);
}

/** The field wrapper that owns the checkbox's form input and state. Compose with `CheckboxControl`. */
export function CheckboxField({ className, ...props }: CheckboxFieldProps) {
return (
<CheckboxFieldPrimitive
data-slot="control"
{...props}
className={cx(
"grid grid-cols-[1.125rem_1fr] gap-x-3 gap-y-1 sm:grid-cols-[1rem_1fr]",
"*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1",
"*:[[slot=errorMessage]]:col-span-full",
"**:data-[slot=control-label]:col-start-2 **:data-[slot=control-label]:row-start-1",
"*:[[slot=description]]:col-start-2 *:[[slot=description]]:row-start-2",
"has-[[slot=description]]:**:data-[slot=control-label]:font-medium",
className,
)}
data-slot="control"
className={cx("has-[[slot=description]]:**:data-[slot=label]:font-medium", className)}
/>
);
}

export function Checkbox({ className, ...props }: CheckboxButtonProps) {
/** The clickable area of a checkbox (indicator + label). Must be rendered inside a `CheckboxField`. */
export function CheckboxControl({ className, children, ...props }: CheckboxButtonProps) {
return (
<CheckboxButton
className={cx("group col-span-full inline-flex gap-x-3 focus:outline-hidden", className)}
className={cx(
"group block [--indicator-mt:--spacing(0.75)] disabled:opacity-50 sm:[--indicator-mt:--spacing(1)]",
className,
)}
{...props}>
{composeRenderProps(props.children, (children, { isSelected, isIndeterminate, isInvalid }) => {
{composeRenderProps(children, (children, { isSelected, isIndeterminate, isFocusVisible, isInvalid }) => {
const isStringChild = typeof children === "string";
const indicator = isIndeterminate ? (
<MinusIcon data-slot="check-indicator" />
) : isSelected ? (
<CheckIcon data-slot="check-indicator" />
) : null;

const content = isStringChild ? <CheckboxLabel>{children}</CheckboxLabel> : children;

return (
<div
className={twMerge(
"grid grid-cols-[1.125rem_1fr] items-center gap-y-1 has-data-[slot=control-label]:gap-x-3 sm:grid-cols-[1rem_1fr]",
"grid grid-cols-[1.125rem_1fr] gap-y-1 has-data-[slot=label]:gap-x-3 sm:grid-cols-[1rem_1fr]",
"*:data-[slot=indicator]:col-start-1 *:data-[slot=indicator]:row-start-1 *:data-[slot=indicator]:mt-(--indicator-mt)",
"*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1",
"*:[[slot=description]]:col-start-2 *:[[slot=description]]:row-start-2",
"has-[[slot=description]]:**:data-[slot=label]:font-medium",
)}>
<span
data-slot="indicator"
className={twMerge([
"relative inset-ring inset-ring-input isolate col-start-1 row-start-1 flex shrink-0 items-center justify-center rounded bg-(--control-bg,transparent) text-bg transition group-hover:inset-ring-muted-fg/30 group-focus-visible:inset-ring-ring",
"size-4.5 *:data-[slot=check-indicator]:size-4 sm:size-4 sm:*:data-[slot=check-indicator]:size-3.5",
"relative inset-ring inset-ring-input isolate flex shrink-0 items-center justify-center rounded bg-(--control-bg,transparent) text-bg transition group-hover:inset-ring-muted-fg/30",
"sm:size-4 sm:*:data-[slot=check-indicator]:size-3.5",
"size-4.5 *:data-[slot=check-indicator]:size-4",
"in-disabled:bg-muted",
(isSelected || isIndeterminate) && [
"inset-ring-(--checkbox-ring,var(--color-ring)) bg-(--checkbox-bg,var(--color-primary)) text-(--checkbox-fg,var(--color-primary-fg))",
"group-invalid:inset-ring/70 group-invalid:bg-danger group-invalid:text-danger-fg dark:group-invalid:inset-ring-danger-subtle-fg/70",
],
isFocusVisible && [
"inset-ring-(--checkbox-ring,var(--color-ring)) ring-(--checkbox-ring,var(--color-ring))/20 ring-3",
"group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:text-danger-fg group-invalid:ring-danger-subtle-fg/20",
],
isInvalid &&
"inset-ring-danger-subtle-fg/70 bg-danger-subtle/5 text-danger-fg ring-danger-subtle-fg/20 group-hover:inset-ring-danger-subtle-fg/70",
])}>
{indicator}
</span>
<Label className="col-start-2 row-start-1" data-slot="control-label" elementType="span">
{children}
</Label>
{content}
</div>
);
})}
</CheckboxButton>
);
}

/** A self-contained checkbox: a `CheckboxField` (owns the input) wrapping a `CheckboxControl`. */
export function Checkbox({ children, className, ...props }: CheckboxFieldProps) {
return (
<CheckboxField {...props}>
<CheckboxControl className={className}>{children}</CheckboxControl>
</CheckboxField>
);
}

export function CheckboxLabel(props: ComponentProps<typeof Label>) {
return <Label elementType="span" data-slot="control-label" {...props} />;
}
9 changes: 4 additions & 5 deletions src/components/ui/choice-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { twMerge } from "tailwind-merge";
import type { VariantProps } from "tailwind-variants";
import { tv } from "tailwind-variants";
import { cx } from "@/lib/primitive";
import { Checkbox, CheckboxField } from "./checkbox";
import { Checkbox } from "./checkbox";

const choiceBoxStyles = tv({
base: "grid [--gutter:--spacing(4)]",
Expand Down Expand Up @@ -166,11 +166,10 @@ const ChoiceBoxItem = ({ className, label, description, children, ...props }: Ch
<>
{content}
{selectionMode === "multiple" && (
<CheckboxField
<Checkbox
className="col-start-2 self-start group-has-[svg:not([data-slot=check-indicator])]:col-start-3 group-has-data-[slot=avatar]:col-start-3"
slot="selection"
className="col-start-2 gap-x-0 self-start group-has-[svg:not([data-slot=check-indicator])]:col-start-3 group-has-data-[slot=avatar]:col-start-3">
<Checkbox className="col-span-1" />
</CheckboxField>
/>
)}
</>
);
Expand Down
6 changes: 2 additions & 4 deletions src/components/ui/grid-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { Text, type TextProps } from "react-aria-components/Text";
import { twMerge } from "tailwind-merge";
import { cx } from "@/lib/primitive";
import { Checkbox, CheckboxField } from "./checkbox";
import { Checkbox } from "./checkbox";

const GridList = <T extends object>({ className, ...props }: GridListProps<T>) => (
<GridListPrimitive
Expand Down Expand Up @@ -67,9 +67,7 @@ const GridListItem = ({ className, children, ...props }: GridListItemProps) => {
)}

{values.selectionMode === "multiple" && values.selectionBehavior === "toggle" && (
<CheckboxField className="gap-x-0" slot="selection">
<Checkbox className="col-span-1" />
</CheckboxField>
<Checkbox className="[--indicator-mt:0] *:gap-x-0 sm:[--indicator-mt:0]" slot="selection" />
)}
{typeof children === "function" ? children(values) : children}
</>
Expand Down
67 changes: 40 additions & 27 deletions src/components/ui/radio.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ComponentProps } from "react";
import { composeRenderProps } from "react-aria-components/composeRenderProps";
import {
RadioButton,
Expand All @@ -17,58 +18,70 @@ export function RadioGroup({ className, ...props }: RadioGroupProps) {
{...props}
data-slot="control"
className={cx(
"space-y-3 has-[[slot=description]]:not-has-[[slot=errorMessage]]:space-y-6 *:data-[slot=label]:font-medium",
"space-y-3 **:data-[slot=label]:font-normal",
"has-[slot=description]:space-y-6 has-[[slot=description]]:**:data-[slot=label]:font-medium",
className,
)}
/>
);
}

/** The field wrapper that owns a radio option's value and input. Compose with `RadioControl`. */
export function RadioField({ className, ...props }: RadioFieldProps) {
return (
<RadioFieldPrimitive
{...props}
className={cx(
"grid grid-cols-[1.125rem_1fr] gap-x-3 gap-y-1 sm:grid-cols-[1rem_1fr]",
"*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1",
"**:data-[slot=control-label]:col-start-2 **:data-[slot=control-label]:row-start-1",
"*:[[slot=description]]:col-start-2 *:[[slot=description]]:row-start-2",
"has-[[slot=description]]:**:data-[slot=control-label]:font-medium",
className,
)}
data-slot="control"
/>
);
return <RadioFieldPrimitive {...props} data-slot="control" className={cx("group block", className)} />;
}

export function Radio({ className, children, ...props }: RadioButtonProps) {
/** The clickable area of a radio (indicator + label). Must be rendered inside a `RadioField`. */
export function RadioControl({ className, children, ...props }: RadioButtonProps) {
return (
<RadioButton
data-slot="control"
className={cx("group col-span-full inline-flex gap-x-3 focus:outline-hidden", className)}
{...props}>
{composeRenderProps(children, (children, { isSelected, isInvalid }) => {
<RadioButton className={cx("group block disabled:opacity-50", className)} {...props}>
{composeRenderProps(children, (children, { isSelected, isFocusVisible, isInvalid }) => {
const isStringChild = typeof children === "string";
const content = isStringChild ? <RadioLabel>{children}</RadioLabel> : children;

return (
<>
<div
className={twMerge(
"grid grid-cols-[1.125rem_1fr] gap-x-3 gap-y-1 sm:grid-cols-[1rem_1fr]",
"*:data-[slot=indicator]:col-start-1 *:data-[slot=indicator]:row-start-1 *:data-[slot=indicator]:mt-0.75 sm:*:data-[slot=indicator]:mt-1",
"*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1",
"*:[[slot=description]]:col-start-2 *:[[slot=description]]:row-start-2",
"has-[[slot=description]]:**:data-[slot=label]:font-medium",
)}>
<span
data-slot="indicator"
className={twMerge([
"relative inset-ring inset-ring-input isolate col-start-1 row-start-1 mt-0.75 flex size-4.5 shrink-0 items-center justify-center rounded-full bg-(--control-bg,transparent) text-bg transition before:absolute before:inset-auto before:size-2 before:shrink-0 before:rounded-full before:content-[''] hover:before:bg-muted-fg/20 sm:mt-1 sm:size-4 sm:before:size-1.7",
"relative inset-ring inset-ring-input isolate flex size-4.5 shrink-0 items-center justify-center rounded-full bg-(--control-bg,transparent) text-bg transition before:absolute before:inset-auto before:size-2 before:shrink-0 before:rounded-full before:content-[''] hover:before:bg-muted-fg/20 sm:size-4 sm:before:size-1.7",
"in-disabled:bg-muted",
isSelected && [
"inset-ring-(--radio-ring,var(--color-ring)) bg-(--radio-bg,var(--color-primary)) text-(--radio-fg,var(--color-primary-fg)) before:bg-bg hover:before:bg-muted/90",
"group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:bg-danger group-invalid:text-danger-fg",
],
isFocusVisible && [
"inset-ring-(--radio-ring,var(--color-ring)) ring-(--radio-ring,var(--color-ring))/20 ring-3",
"group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:text-danger-fg group-invalid:ring-danger-subtle-fg/20",
],
isInvalid &&
"inset-ring-danger-subtle-fg/70 bg-danger-subtle/5 text-danger-fg ring-danger-subtle-fg/20",
])}
/>
<Label data-slot="control-label" elementType="span">
{children}
</Label>
</>
{content}
</div>
);
})}
</RadioButton>
);
}

/** A self-contained radio option: a `RadioField` (owns the value/input) wrapping a `RadioControl`. */
export function Radio({ children, className, ...props }: RadioFieldProps) {
return (
<RadioField {...props}>
<RadioControl className={className}>{children}</RadioControl>
</RadioField>
);
}

export function RadioLabel(props: ComponentProps<typeof Label>) {
return <Label elementType="span" data-slot="control-label" {...props} />;
}
Loading