Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: Checkbox.Group #1003

Merged
merged 4 commits into from
Dec 15, 2024
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
5 changes: 5 additions & 0 deletions .changeset/grumpy-clouds-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": minor
---

feat: `Checkbox.Group` and `Checkbox.GroupLabel` components
2 changes: 2 additions & 0 deletions packages/bits-ui/src/lib/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ declare global {
var bitsEscapeLayers: Map<EscapeLayerState, ReadableBox<EscapeBehaviorType>>;
// eslint-disable-next-line vars-on-top, no-var
var bitsTextSelectionLayers: Map<TextSelectionLayerState, ReadableBox<boolean>>;
// eslint-disable-next-line vars-on-top, no-var
var bitsIdCounter: { current: number };
}
203 changes: 186 additions & 17 deletions packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,110 @@
import { srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt";
import type { HTMLButtonAttributes } from "svelte/elements";
import { watch } from "runed";
import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js";
import type { BitsKeyboardEvent, BitsMouseEvent, WithRefProps } from "$lib/internal/types.js";
import { getAriaChecked, getAriaRequired, getDataDisabled } from "$lib/internal/attrs.js";
import { kbd } from "$lib/internal/kbd.js";
import { createContext } from "$lib/internal/create-context.js";

const CHECKBOX_ROOT_ATTR = "data-checkbox-root";
const CHECKBOX_GROUP_ATTR = "data-checkbox-group";
const CHECKBOX_GROUP_LABEL_ATTR = "data-checkbox-group-label";

type CheckboxGroupStateProps = WithRefProps<
ReadableBoxedValues<{
name: string | undefined;
disabled: boolean;
required: boolean;
}> &
WritableBoxedValues<{
value: string[];
}>
>;

class CheckboxGroupState {
id: CheckboxGroupStateProps["id"];
ref: CheckboxGroupStateProps["ref"];
value: CheckboxGroupStateProps["value"];
disabled: CheckboxGroupStateProps["disabled"];
required: CheckboxGroupStateProps["required"];
name: CheckboxGroupStateProps["name"];
labelId = $state<string | undefined>(undefined);

constructor(props: CheckboxGroupStateProps) {
this.id = props.id;
this.ref = props.ref;
this.value = props.value;
this.disabled = props.disabled;
this.required = props.required;
this.name = props.name;

useRefById({
id: this.id,
ref: this.ref,
});
}

addValue(checkboxValue: string | undefined) {
if (!checkboxValue) return;
if (!this.value.current.includes(checkboxValue)) {
this.value.current.push(checkboxValue);
}
}

removeValue(checkboxValue: string | undefined) {
if (!checkboxValue) return;
const index = this.value.current.indexOf(checkboxValue);
if (index === -1) return;
this.value.current.splice(index, 1);
}

props = $derived.by(
() =>
({
id: this.id.current,
role: "group",
"aria-labelledby": this.labelId,
"data-disabled": getDataDisabled(this.disabled.current),
[CHECKBOX_GROUP_ATTR]: "",
}) as const
);
}

type CheckboxGroupLabelStateProps = WithRefProps;

class CheckboxGroupLabelState {
id: CheckboxGroupLabelStateProps["id"];
ref: CheckboxGroupLabelStateProps["ref"];
group: CheckboxGroupState;

constructor(props: CheckboxGroupLabelStateProps, group: CheckboxGroupState) {
this.id = props.id;
this.ref = props.ref;
this.group = group;

useRefById({
id: this.id,
ref: this.ref,
onRefChange: (node) => {
if (node) {
group.labelId = node.id;
} else {
group.labelId = undefined;
}
},
});
}

props = $derived.by(
() =>
({
id: this.id.current,
"data-disabled": getDataDisabled(this.group.disabled.current),
[CHECKBOX_GROUP_LABEL_ATTR]: "",
}) as const
);
}

type CheckboxRootStateProps = WithRefProps<
ReadableBoxedValues<{
Expand All @@ -27,33 +125,75 @@ class CheckboxRootState {
#ref: CheckboxRootStateProps["ref"];
#type: CheckboxRootStateProps["type"];
checked: CheckboxRootStateProps["checked"];
disabled: CheckboxRootStateProps["disabled"];
required: CheckboxRootStateProps["required"];
name: CheckboxRootStateProps["name"];
#disabled: CheckboxRootStateProps["disabled"];
#required: CheckboxRootStateProps["required"];
#name: CheckboxRootStateProps["name"];
value: CheckboxRootStateProps["value"];
indeterminate: CheckboxRootStateProps["indeterminate"];
group: CheckboxGroupState | null = null;

trueName = $derived.by(() => {
if (this.group && this.group.name.current) {
return this.group.name.current;
} else {
return this.#name.current;
}
});
trueRequired = $derived.by(() => {
if (this.group && this.group.required.current) {
return true;
}
return this.#required.current;
});
trueDisabled = $derived.by(() => {
if (this.group && this.group.disabled.current) {
return true;
}
return this.#disabled.current;
});

constructor(props: CheckboxRootStateProps) {
constructor(props: CheckboxRootStateProps, group: CheckboxGroupState | null = null) {
this.checked = props.checked;
this.disabled = props.disabled;
this.required = props.required;
this.name = props.name;
this.#disabled = props.disabled;
this.#required = props.required;
this.#name = props.name;
this.value = props.value;
this.#ref = props.ref;
this.#id = props.id;
this.indeterminate = props.indeterminate;
this.#type = props.type;
this.group = group;
this.onkeydown = this.onkeydown.bind(this);
this.onclick = this.onclick.bind(this);

useRefById({
id: this.#id,
ref: this.#ref,
});

watch(
[() => $state.snapshot(this.group?.value.current), () => this.value.current],
([groupValue, value]) => {
if (!groupValue || !value) return;
this.checked.current = groupValue.includes(value);
}
);

watch(
() => this.checked.current,
(checked) => {
if (!this.group) return;
if (checked) {
this.group?.addValue(this.value.current);
} else {
this.group?.removeValue(this.value.current);
}
}
);
}

onkeydown(e: BitsKeyboardEvent) {
if (this.disabled.current) return;
if (this.#disabled.current) return;
if (e.key === kbd.ENTER) e.preventDefault();
if (e.key === kbd.SPACE) {
e.preventDefault();
Expand All @@ -71,20 +211,25 @@ class CheckboxRootState {
}

onclick(_: BitsMouseEvent) {
if (this.disabled.current) return;
if (this.#disabled.current) return;
this.#toggle();
}

snippetProps = $derived.by(() => ({
checked: this.checked.current,
indeterminate: this.indeterminate.current,
}));

props = $derived.by(
() =>
({
id: this.#id.current,
role: "checkbox",
type: this.#type.current,
disabled: this.disabled.current,
disabled: this.trueDisabled,
"aria-checked": getAriaChecked(this.checked.current, this.indeterminate.current),
"aria-required": getAriaRequired(this.required.current),
"data-disabled": getDataDisabled(this.disabled.current),
"aria-required": getAriaRequired(this.trueRequired),
"data-disabled": getDataDisabled(this.trueDisabled),
"data-state": getCheckboxDataState(
this.checked.current,
this.indeterminate.current
Expand All @@ -103,7 +248,20 @@ class CheckboxRootState {

class CheckboxInputState {
root: CheckboxRootState;
shouldRender = $derived.by(() => Boolean(this.root.name.current));
trueChecked = $derived.by(() => {
if (this.root.group) {
if (
this.root.value.current !== undefined &&
this.root.group.value.current.includes(this.root.value.current)
) {
return true;
}
return false;
}
return this.root.checked.current;
});

shouldRender = $derived.by(() => Boolean(this.root.trueName));

constructor(root: CheckboxRootState) {
this.root = root;
Expand All @@ -114,9 +272,9 @@ class CheckboxInputState {
({
type: "checkbox",
checked: this.root.checked.current === true,
disabled: this.root.disabled.current,
required: this.root.required.current,
name: this.root.name.current,
disabled: this.root.trueDisabled,
required: this.root.trueRequired,
name: this.root.trueName,
value: this.root.value.current,
"aria-hidden": "true",
style: styleToString(srOnlyStyles),
Expand All @@ -139,11 +297,22 @@ function getCheckboxDataState(checked: boolean, indeterminate: boolean) {
// CONTEXT METHODS
//

const [setCheckboxGroupContext, getCheckboxGroupContext] =
createContext<CheckboxGroupState>("Checkbox.Group");

const [setCheckboxRootContext, getCheckboxRootContext] =
createContext<CheckboxRootState>("Checkbox.Root");

export function useCheckboxGroup(props: CheckboxGroupStateProps) {
return setCheckboxGroupContext(new CheckboxGroupState(props));
}

export function useCheckboxRoot(props: CheckboxRootStateProps) {
return setCheckboxRootContext(new CheckboxRootState(props));
return setCheckboxRootContext(new CheckboxRootState(props, getCheckboxGroupContext(null)));
}

export function useCheckboxGroupLabel(props: CheckboxGroupLabelStateProps) {
return new CheckboxGroupLabelState(props, getCheckboxGroupContext());
}

export function useCheckboxInput(): CheckboxInputState {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { CheckboxGroupLabelProps } from "../types.js";
import { useCheckboxGroupLabel } from "../checkbox.svelte.js";
import { useId } from "$lib/internal/use-id.js";

let {
ref = $bindable(null),
id = useId(),
child,
children,
...restProps
}: CheckboxGroupLabelProps = $props();

const labelState = useCheckboxGroupLabel({
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => (ref = v)
),
});

const mergedProps = $derived(mergeProps(restProps, labelState.props));
</script>

{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { CheckboxGroupProps } from "../types.js";
import { useCheckboxGroup } from "../checkbox.svelte.js";
import { noop } from "$lib/internal/noop.js";
import { useId } from "$lib/internal/use-id.js";

let {
ref = $bindable(null),
id = useId(),
value = $bindable([]),
onValueChange = noop,
name,
required,
disabled,
children,
child,
...restProps
}: CheckboxGroupProps = $props();

const groupState = useCheckboxGroup({
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => (ref = v)
),
disabled: box.with(() => Boolean(disabled)),
required: box.with(() => Boolean(required)),
name: box.with(() => name),
value: box.with(
() => value,
(v) => onValueChange(v)
),
});

const mergedProps = $derived(mergeProps(restProps, groupState.props));
</script>

{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,11 @@
{#if child}
{@render child({
props: mergedProps,
checked: rootState.checked.current,
indeterminate: rootState.indeterminate.current,
...rootState.snippetProps,
})}
{:else}
<button {...mergedProps}>
{@render children?.({
checked: rootState.checked.current,
indeterminate: rootState.indeterminate.current,
})}
{@render children?.(rootState.snippetProps)}
</button>
{/if}

Expand Down
Loading
Loading