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
9 changes: 9 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run test:unit

component:
name: Component Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run test:component

tier1:
name: Tier 1 – Structural Validation
runs-on: ubuntu-latest
Expand Down
371 changes: 181 additions & 190 deletions bun.lock

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"packageManager": "bun@1.3.6",
"scripts": {
"dev": "bun --bun vite dev",
"dev": "vite dev",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"check": "bun --bun svelte-kit sync && bun --bun svelte-check --tsconfig ./tsconfig.json",
Expand All @@ -13,21 +13,25 @@
"format": "bun --bun prettier --write .",
"test": "vitest run tests/",
"test:tier1": "vitest run tests/validate/tier1.test.ts",
"test:unit": "vitest run tests/unit/"
"test:unit": "vitest run tests/unit/",
"test:component": "vitest run tests/component/"
},
"devDependencies": {
"@internationalized/date": "^3.12.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/bun": "^1.3.13",
"@types/eslint": "^9.6.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.55.5",
Expand All @@ -37,7 +41,7 @@
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.1",
"vite": "^5.4.21",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/components/new/ModernStratView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
</div>

<div class="space-y-8 lg:space-y-12">
{#each individualStrat as phase}
{#each individualStrat as phase (phase.phaseName)}
{#if tabTags && tabTags[tab] && useMainPageTabs ? tabTags[tab].includes(phase.tag) : true}
{#if phase?.mechs}
<Collapsible.Root
Expand Down
31 changes: 28 additions & 3 deletions src/lib/components/ui/collapsible/collapsible-content.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { getCollapsibleContext } from './context';

let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
let {
ref = $bindable(null),
children,
forceMount = false,
class: className,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
ref?: HTMLDivElement | null;
children?: Snippet;
forceMount?: boolean;
} = $props();

const ctx = getCollapsibleContext();
</script>

<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
{#if forceMount || ctx.open()}
<div
bind:this={ref}
id={ctx.contentId}
data-slot="collapsible-content"
data-state={ctx.open() ? 'open' : 'closed'}
class={className}
{...restProps}
>
{@render children?.()}
</div>
{/if}
46 changes: 43 additions & 3 deletions src/lib/components/ui/collapsible/collapsible-trigger.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import { getCollapsibleContext } from './context';

let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
let {
ref = $bindable(null),
children,
child,
onclick,
class: className,
...restProps
}: HTMLButtonAttributes & {
ref?: HTMLButtonElement | null;
children?: Snippet;
/**
* Render-as-child escape hatch: receives the trigger props that would
* otherwise be applied to a `<button>` so a custom element (e.g.
* `<Sidebar.MenuButton>`) can take over the trigger role.
*/
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();

const ctx = getCollapsibleContext();

const triggerProps = $derived({
type: 'button' as const,
'data-slot': 'collapsible-trigger',
'data-state': ctx.open() ? 'open' : 'closed',
'aria-expanded': ctx.open(),
'aria-controls': ctx.contentId,
class: className,
onclick: (event: MouseEvent) => {
ctx.setOpen(!ctx.open());
onclick?.(event as never);
},
...restProps
});
</script>

<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
{#if child}
{@render child({ props: triggerProps })}
{:else}
<button bind:this={ref} {...triggerProps}>
{@render children?.()}
</button>
{/if}
36 changes: 33 additions & 3 deletions src/lib/components/ui/collapsible/collapsible.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { setCollapsibleContext } from './context';

let {
ref = $bindable(null),
open = $bindable(false),
onOpenChange,
children,
class: className,
...restProps
}: CollapsiblePrimitive.RootProps = $props();
}: HTMLAttributes<HTMLDivElement> & {
ref?: HTMLDivElement | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: Snippet;
} = $props();

const id = $props.id();
const contentId = `${id}-content`;

setCollapsibleContext({
open: () => open,
setOpen: (value: boolean) => {
open = value;
onOpenChange?.(value);
},
contentId
});
</script>

<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
<div
bind:this={ref}
data-slot="collapsible"
data-state={open ? 'open' : 'closed'}
class={className}
{...restProps}
>
{@render children?.()}
</div>
23 changes: 23 additions & 0 deletions src/lib/components/ui/collapsible/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getContext, setContext } from 'svelte';

export type CollapsibleContext = {
open: () => boolean;
setOpen: (value: boolean) => void;
contentId: string;
};

const KEY = Symbol('collapsible');

export function setCollapsibleContext(ctx: CollapsibleContext): CollapsibleContext {
return setContext(KEY, ctx);
}

export function getCollapsibleContext(): CollapsibleContext {
const ctx = getContext<CollapsibleContext | undefined>(KEY);
if (!ctx) {
throw new Error(
'Collapsible.Trigger and Collapsible.Content must be inside a Collapsible.Root'
);
}
return ctx;
}
16 changes: 16 additions & 0 deletions tests/component/collapsible-child-harness.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import * as Collapsible from '../../src/lib/components/ui/collapsible';
</script>

<!--
Exercises the `child` snippet escape hatch on Collapsible.Trigger that the
sidebar uses to render a custom element instead of the default `<button>`.
-->
<Collapsible.Root>
<Collapsible.Trigger>
{#snippet child({ props })}
<a href="#test" data-testid="custom-trigger" {...props}>Custom</a>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>Body</Collapsible.Content>
</Collapsible.Root>
22 changes: 22 additions & 0 deletions tests/component/collapsible-harness.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script lang="ts">
import * as Collapsible from '../../src/lib/components/ui/collapsible';

interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
triggerLabel?: string;
contentText?: string;
}

let {
open = $bindable(false),
onOpenChange,
triggerLabel = 'Toggle',
contentText = 'Hidden content'
}: Props = $props();
</script>

<Collapsible.Root bind:open {onOpenChange}>
<Collapsible.Trigger>{triggerLabel}</Collapsible.Trigger>
<Collapsible.Content>{contentText}</Collapsible.Content>
</Collapsible.Root>
100 changes: 100 additions & 0 deletions tests/component/collapsible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment jsdom

/**
* Tests for the bespoke Collapsible component (replacement for bits-ui's
* Collapsible to avoid a derived_inert warning under svelte 5.55+). Covers
* the public API: open state, trigger toggling, onOpenChange callback,
* conditional content rendering, ARIA attributes, and the `child` snippet
* escape hatch used by the sidebar.
*/

import { afterEach, describe, expect, test } from 'vitest';
import { cleanup, render, fireEvent } from '@testing-library/svelte';
import Harness from './collapsible-harness.svelte';
import ChildHarness from './collapsible-child-harness.svelte';

afterEach(() => cleanup());

describe('Collapsible', () => {
test('renders closed by default', () => {
const { queryByText, getByRole } = render(Harness);
const trigger = getByRole('button', { name: 'Toggle' });

expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(queryByText('Hidden content')).toBeNull();
});

test('renders open when initial open=true', () => {
const { getByText, getByRole } = render(Harness, { props: { open: true } });
const trigger = getByRole('button', { name: 'Toggle' });

expect(trigger.getAttribute('aria-expanded')).toBe('true');
expect(trigger.getAttribute('data-state')).toBe('open');
expect(getByText('Hidden content')).toBeTruthy();
});

test('clicking trigger toggles open state', async () => {
const { getByRole, queryByText, getByText } = render(Harness);
const trigger = getByRole('button', { name: 'Toggle' });

expect(queryByText('Hidden content')).toBeNull();

await fireEvent.click(trigger);
expect(getByText('Hidden content')).toBeTruthy();
expect(trigger.getAttribute('aria-expanded')).toBe('true');

await fireEvent.click(trigger);
expect(queryByText('Hidden content')).toBeNull();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});

test('onOpenChange fires with the new state', async () => {
const calls: boolean[] = [];
const { getByRole } = render(Harness, {
props: { onOpenChange: (v: boolean) => calls.push(v) }
});
const trigger = getByRole('button', { name: 'Toggle' });

await fireEvent.click(trigger);
await fireEvent.click(trigger);
await fireEvent.click(trigger);

expect(calls).toEqual([true, false, true]);
});

test('trigger has aria-controls linking to content id', async () => {
const { getByRole, getByText } = render(Harness, { props: { open: true } });
const trigger = getByRole('button', { name: 'Toggle' });
const content = getByText('Hidden content');

const controls = trigger.getAttribute('aria-controls');
expect(controls).toBeTruthy();
expect(content.id).toBe(controls);
});

test('content has data-state matching open state', async () => {
const { getByText, getByRole } = render(Harness, { props: { open: true } });
expect(getByText('Hidden content').getAttribute('data-state')).toBe('open');

// Toggle closed; content unmounts entirely (no `closed` data-state to check)
await fireEvent.click(getByRole('button', { name: 'Toggle' }));
// No assertion needed — `queryByText` after this would return null, covered above
});

test('child snippet replaces the default <button> with a custom element', async () => {
const { getByTestId, queryByText } = render(ChildHarness);
const customTrigger = getByTestId('custom-trigger');

// Custom <a> took over — no default <button> rendered
expect(customTrigger.tagName).toBe('A');
// Trigger props are forwarded — aria-expanded reflects state
expect(customTrigger.getAttribute('aria-expanded')).toBe('false');
expect(customTrigger.getAttribute('aria-controls')).toBeTruthy();

// Click still toggles open state
expect(queryByText('Body')).toBeNull();
await fireEvent.click(customTrigger);
expect(customTrigger.getAttribute('aria-expanded')).toBe('true');
});
});
16 changes: 12 additions & 4 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
plugins: [tailwindcss(), sveltekit()] as any,
plugins: [tailwindcss(), sveltekit()],
test: {
include: ['tests/**/*.test.ts']
}
include: ['tests/**/*.test.ts'],
// Resolve svelte to its client build during tests so `@testing-library/svelte`
// can mount components (the default node-conditions resolve picks the
// server-only build, where `mount()` is unavailable).
server: {
deps: {
inline: ['@testing-library/svelte']
}
}
},
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined
});
Loading