Skip to content

Feat/Avatar builder and component #16

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

Merged
merged 9 commits into from
Jan 22, 2025
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/red-pans-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"melt": minor
---

feat: add avatar
52 changes: 52 additions & 0 deletions docs/src/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -771,5 +771,57 @@
}
],
"propsAlt": "export type CollapsibleProps = {\n /**\n * Whether the collapsible is disabled which prevents it from being opened.\n */\n disabled?: MaybeGetter<boolean | undefined>;\n\n /**\n * Whether the collapsible is open.\n */\n open?: MaybeGetter<boolean | undefined>;\n\n /**\n * A callback called when the value of `open` changes.\n */\n onOpenChange?: (value: boolean) => void;\n};"
},
"Avatar": {
"constructorProps": [
{
"name": "src",
"type": "MaybeGetter<string | undefined>",
"description": "The source of the image to display.",
"optional": true
},
{
"name": "delayMs",
"type": "MaybeGetter<number | undefined>",
"description": "The amount of time in milliseconds to wait before displaying the image.",
"defaultValue": "0",
"optional": true
},
{
"name": "onLoadingStatusChange",
"type": "((value: ImageLoadingStatus) => void | undefined) | undefined",
"description": "A callback invoked when the loading status store of the avatar changes.",
"optional": true
}
],
"methods": [],
"properties": [
{
"name": "src",
"type": "string",
"description": ""
},
{
"name": "delayMs",
"type": "number",
"description": ""
},
{
"name": "loadingStatus",
"type": "ImageLoadingStatus",
"description": ""
},
{
"name": "image",
"type": "{\n readonly \"data-melt-avatar-image\": \"\"\n readonly src: string\n readonly style: `display: ${string}`\n readonly onload: () => (() => void) | undefined\n readonly onerror: () => void\n}",
"description": ""
},
{
"name": "fallback",
"type": "{\n readonly \"data-melt-avatar-fallback\": \"\"\n readonly style: `display: ${string}` | undefined\n readonly hidden: true | undefined\n}",
"description": ""
}
],
"propsAlt": "export type AvatarProps = {\n /**\n * The source of the image to display.\n */\n src?: MaybeGetter<string | undefined>;\n\n /**\n * The amount of time in milliseconds to wait before displaying the image.\n *\n * @default 0\n */\n delayMs?: MaybeGetter<number | undefined>;\n\n /**\n * A callback invoked when the loading status store of the avatar changes.\n */\n onLoadingStatusChange?: (value: ImageLoadingStatus) => void | undefined;\n};"
}
}
32 changes: 19 additions & 13 deletions docs/src/components/preview-ctx.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
import { getContext, setContext } from "svelte";
import { objectMap } from "@antfu/utils";

const CTX_KEY = Symbol();

function set<Schema extends SchemaExtends>(ctx: Context<Schema>) {
return setContext(CTX_KEY, ctx);
}

function get<Schema extends SchemaExtends>() {
return getContext<Context<Schema>>(CTX_KEY) ?? {};
}

export const previewCtx = { get, set };

// A type that marks all readonly values as writable
type Writable<T> = {
-readonly [P in keyof T]: T[P];
Expand All @@ -39,7 +27,13 @@ type NumberControl = {
max?: number;
};

type Control = BooleanControl | SelectControl | NumberControl;
type StringControl = {
label: string;
defaultValue: string;
type: "string";
};

type Control = BooleanControl | SelectControl | NumberControl | StringControl;

type NormalizeType<T> = T extends string
? T
Expand All @@ -63,6 +57,18 @@ type Context<Schema extends SchemaExtends> = {
schema: Schema;
};

const CTX_KEY = Symbol();

function set<Schema extends SchemaExtends>(ctx: Context<Schema>) {
return setContext(CTX_KEY, ctx);
}

function get<Schema extends SchemaExtends>() {
return getContext<Context<Schema>>(CTX_KEY) ?? {};
}

export const previewCtx = { get, set };

export function usePreviewControls<const Schema extends SchemaExtends>(
schema: Schema,
): Writable<Context<Schema>["values"]> {
Expand Down
10 changes: 8 additions & 2 deletions docs/src/components/preview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
{:else if control.type === "select"}
<select
bind:value={values[key] as string}
class="self-stretch rounded-md bg-gray-900 px-1 py-0.5"
class="self-stretch rounded-md px-1 py-0.5 dark:bg-gray-900"
>
{#each control.options as option}
<option value={option}>{option}</option>
Expand All @@ -117,7 +117,13 @@
bind:value={values[key] as number}
min={control.min}
max={control.max}
class="self-stretch rounded-md bg-gray-900 px-1 py-0.5"
class="self-stretch rounded-md px-1 py-0.5 dark:bg-gray-900"
/>
{:else if control.type === "string"}
<input
type="text"
bind:value={values[key] as string}
class="self-stretch rounded-md px-1 py-0.5 dark:bg-gray-900"
/>
{/if}
</label>
Expand Down
64 changes: 64 additions & 0 deletions docs/src/content/docs/components/avatar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
title: Avatar
description: An image element with a fallback for representing the user.
---
import ApiTable from "@components/api-table.astro";
import Preview from "@previews/avatar.svelte";
import Features from "@components/features.astro";
import ThemedCode from "@components/themed-code.astro";
import { Tabs, TabItem } from '@astrojs/starlight/components';

{/*
Things I want:
- Preview with props DONE
- Show builder syntax
- Show component syntax
- Features
*/}

<Preview client:load />

## Features

<Features>
- 🎮 Automatic & manual control over image rendering
- 🗼 Fallback supports any children elements
- ⌛ Optionally delay fallback rendering to avoid flashes
</Features>

## Usage

<Tabs>
<TabItem label="Builder">
```svelte
<script lang="ts">
import { Avatar } from "melt";

const avatar = new Avatar({src: '...'});
</script>

<img {...avatar.image} alt="Avatar" />
<span {...avatar.fallback}>RH</span>

```
</TabItem>

<TabItem label="Component">
```svelte
<script lang="ts">
import { Avatar } from "melt/components";
</script>

<Avatar src="...">
{#snippet children(avatar)}
<img {...avatar.image} alt="Avatar" />
<span {...avatar.fallback}>Fallback</span>
{/snippet}
</PinInput>
```
</TabItem>
</Tabs>

## API Reference

<ApiTable entry="Avatar" />
73 changes: 73 additions & 0 deletions docs/src/previews/avatar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script lang="ts">
import { usePreviewControls } from "@components/preview-ctx.svelte";
import Preview from "@components/preview.svelte";
import { Avatar, getters } from "melt/builders";
import { Debounced } from "runed";

const controls = usePreviewControls({
delayMs: {
label: "Delay (ms)",
type: "number",
defaultValue: 650,
},
});

let username = $state("rich-harris");
const src = new Debounced(() => `https://github.com/${username}.png`, 500);

const getInitials = (username: string): string => {
// Handle empty strings
if (!username) return "";

// Split by common separators and handle camelCase/PascalCase
const parts = username
// Insert space before capitals in camelCase/PascalCase
.replace(/([a-z])([A-Z])/g, "$1 $2")
// Split by common separators
.split(/[\s\-_\/.]+/)
// Remove empty parts
.filter((part) => part.length > 0);

// Get first letter of first part
const firstInitial = parts[0]?.[0]?.toUpperCase() || "";

// Get first letter of last part if different from first part
const lastInitial = parts.length > 1 ? parts[parts.length - 1]?.[0]?.toUpperCase() : "";

return firstInitial + (lastInitial === firstInitial ? "" : lastInitial);
};
const initials = $derived(getInitials(username));

const avatar = new Avatar({
src: () => src.current,
...getters(controls),
});
</script>

<Preview>
<div class="flex flex-col items-center">
<div class="flex w-full items-center justify-center gap-6">
<div class="flex size-32 items-center justify-center rounded-full bg-neutral-100">
<img {...avatar.image} alt="Avatar" class="h-full w-full rounded-[inherit]" />
<span {...avatar.fallback} class="text-magnum-700 text-4xl font-medium">{initials}</span>
</div>
</div>
<label for="gh" class="mt-4"> GitHub username </label>
<span
contenteditable
id="gh"
class=" w-auto border-b-2 border-neutral-600 bg-transparent px-1 pb-1 text-center text-2xl font-light
text-white placeholder-neutral-500 outline-none transition focus:border-neutral-200"
bind:innerText={username}
spellcheck="false"
></span>
<span
class={[
"mt-2 text-red-300",
avatar.loadingStatus !== "error" && "pointer-events-none opacity-0",
]}
>
invalid username
</span>
</div>
</Preview>
84 changes: 84 additions & 0 deletions packages/melt/src/lib/builders/Avatar.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { extract } from "$lib/utils/extract";
import { createDataIds } from "$lib/utils/identifiers";
import { styleAttr } from "$lib/utils/attribute";
import { inBrowser } from "$lib/utils/browser";
import type { MaybeGetter } from "$lib/types";
import { watch } from "runed";

const identifiers = createDataIds("avatar", ["image", "fallback"]);

export type ImageLoadingStatus = "loading" | "loaded" | "error";

export type AvatarProps = {
/**
* The source of the image to display.
*/
src?: MaybeGetter<string | undefined>;

/**
* The amount of time in milliseconds to wait before displaying the image.
*
* @default 0
*/
delayMs?: MaybeGetter<number | undefined>;

/**
* A callback invoked when the loading status store of the avatar changes.
*/
onLoadingStatusChange?: (value: ImageLoadingStatus) => void | undefined;
};

export class Avatar {
/* Props */
#props!: AvatarProps;
readonly src = $derived(extract(this.#props.src, ""));
readonly delayMs = $derived(extract(this.#props.delayMs, 0));

/* State */
#loadingStatus: ImageLoadingStatus = $state("loading");

constructor(props: AvatarProps = {}) {
$effect(() => {
this.#props.onLoadingStatusChange?.(this.#loadingStatus);
});

watch(
() => this.src,
() => {
this.#loadingStatus = "loading";
},
);

this.#props = props;
}

get loadingStatus() {
return this.#loadingStatus;
}

get image() {
return {
[identifiers.image]: "",
src: this.src,
style: styleAttr({ display: this.#loadingStatus === "loaded" ? "block" : "none" }),
onload: () => {
if (!inBrowser()) return;
const timerId = window.setTimeout(() => {
this.#loadingStatus = "loaded";
}, this.delayMs);
return () => window.clearTimeout(timerId);
},
onerror: () => {
this.#loadingStatus = "error";
},
} as const;
}

get fallback() {
return {
[identifiers.fallback]: "",
style: this.#loadingStatus === "loaded" ? styleAttr({ display: "none" }) : undefined,
hidden: this.#loadingStatus === "loaded" ? true : undefined,
} as const;
}
}
1 change: 1 addition & 0 deletions packages/melt/src/lib/builders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./Toggle.svelte";
export * from "./Slider.svelte";
export * from "./utils.svelte";
export * from "./Tree.svelte";
export * from "./Avatar.svelte";
17 changes: 17 additions & 0 deletions packages/melt/src/lib/components/Avatar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar, type AvatarProps, type ImageLoadingStatus } from "../builders/Avatar.svelte";
import { type Snippet } from "svelte";
import type { ComponentProps } from "../types";
import { getters } from "$lib/builders";

type Props = ComponentProps<AvatarProps> & {
children: Snippet<[Avatar]>;
onLoadingStatusChange?: (value: ImageLoadingStatus) => void | undefined;
};

let { children, onLoadingStatusChange, ...rest }: Props = $props();

const avatar = new Avatar(getters({ ...rest }));
</script>

{@render children(avatar)}
1 change: 1 addition & 0 deletions packages/melt/src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { default as Slider } from "./Slider.svelte";
export { default as Popover } from "./Popover.svelte";
export { default as Progress } from "./Progress.svelte";
export { default as RadioGroup } from "./RadioGroup.svelte";
export { default as Avatar } from "./Avatar.svelte";