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

Feat/Avatar builder and component #16

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
44 changes: 43 additions & 1 deletion docs/src/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -555,5 +555,47 @@
}
],
"propsAlt": "export type PinInputProps = {\n /**\n * The value for the Pin Input.\n *\n * When passing a getter, it will be used as source of truth,\n * meaning that the value only changes when the getter returns a new value.\n *\n * Otherwise, if passing a static value, it'll serve as the default value.\n *\n *\n * @default ''\n */\n value?: MaybeGetter<string | undefined>;\n /**\n * Called when the `PinInput` instance tries to change the value.\n */\n onValueChange?: (value: string) => void;\n\n /**\n * The amount of digits in the Pin Input.\n *\n * @default 4\n */\n maxLength?: MaybeGetter<number | undefined>;\n /**\n * An optional placeholder to display when the input is empty.\n *\n * @default '○'\n */\n placeholder?: MaybeGetter<string | undefined>;\n\n /**\n * If `true`, prevents the user from interacting with the input.\n *\n * @default false\n */\n disabled?: MaybeGetter<boolean | undefined>;\n\n /**\n * If the input should be masked like a password.\n *\n * @default false\n */\n mask?: MaybeGetter<boolean | undefined>;\n\n /**\n * What characters the input accepts.\n *\n * @default 'text'\n */\n type?: MaybeGetter<\"alphanumeric\" | \"numeric\" | \"text\" | undefined>;\n};"
},
"Avatar": {
"constructorProps": [
{
"name": "src",
"type": "MaybeGetter<string | undefined>",
"description": "Source for the avatar image",
"defaultValue": "undefined",
"optional": true
},
{
"name": "delayMs",
"type": "MaybeGetter<number | undefined>",
"description": "Delay in milliseconds before the fallback is loaded, to prevent flickering",
"defaultValue": "undefined",
"optional": true
},
{
"name": "onLoadingStatusChange",
"type": "MaybeGetter<((value: 'loading' | 'loaded' | 'error') => void) | undefined>",
"description": "Callback fired when the loading status changes",
"defaultValue": "undefined",
"optional": true
}
],
"methods": [],
"properties": [
{
"name": "loadingStatus",
"type": "'loading' | 'loaded' | 'error'",
"description": "The current loading status of the image"
},
{
"name": "image",
"type": "{\n readonly \"data-melt-avatar-image\": \"\"\n readonly src: string | undefined\n readonly style: string\n readonly onload: () => void\n readonly onerror: () => void\n}"
},
{
"name": "fallback",
"type": "{\n readonly \"data-melt-avatar-fallback\": \"\"\n readonly style: string | undefined\n readonly hidden: boolean | undefined\n}"
}
],
"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?: MaybeGetter<(value: ImageLoadingStatus) => void | undefined>;\n};"
}
}
}
18 changes: 15 additions & 3 deletions docs/src/components/preview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
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 Down Expand Up @@ -177,7 +183,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 bg-gray-900 px-1 py-0.5 text-gray-100"
>
{#each control.options as option}
<option value={option}>{option}</option>
Expand All @@ -189,7 +195,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 bg-gray-900 px-1 py-0.5 text-gray-100"
/>
{:else if control.type === "string"}
<input
type="text"
bind:value={values[key] as string}
class="self-stretch rounded-md bg-gray-900 px-1 py-0.5 text-gray-100"
/>
{/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: 'https://avatars.githubusercontent.com/u/1162160?v=4'});
</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="https://avatars.githubusercontent.com/u/1162160?v=4">
{#snippet children(avatar)}
<img {...avatar.image} alt="Avatar" />
<span {...avatar.fallback} >RH</span>
{/snippet}
</PinInput>
```
</TabItem>
</Tabs>

## API Reference

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

const controls = usePreviewControls({
src: {
label: "Source",
defaultValue: "https://avatars.githubusercontent.com/u/1162160?v=4",
type: "string",
},
delayMs: {
label: "Delay (ms)",
type: "number",
defaultValue: 650,
},
});
</script>

<Preview>
<Avatar {...controls}>
{#snippet children(avatar)}
<div class="flex w-full items-center justify-center gap-6">
<div class="flex h-16 w-16 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-3xl font-medium">RH</span>
</div>
</div>
{/snippet}
</Avatar>
</Preview>
83 changes: 83 additions & 0 deletions packages/melt/src/lib/builders/Avatar.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { extract } from "$lib/utils/extract";
import { Synced } from "$lib/Synced.svelte";
import { createDataIds } from "$lib/utils/identifiers";
import { styleAttr } from "$lib/utils/attribute";
import { inBrowser } from "$lib/utils/browser";
import { MaybeGetter } from "$lib/types";

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?: MaybeGetter<(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!: Synced<ImageLoadingStatus>;

constructor(props: AvatarProps = {}) {
this.#loadingStatus = new Synced({
value: "loading",
onChange: props.onLoadingStatusChange,
defaultValue: "loading",
});
this.#props = props;
}

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

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

get fallback() {
return {
[identifiers.fallback]: "",
style: styleAttr({ display: this.#loadingStatus.current === "loaded" ? "none" : undefined }),
hidden: this.#loadingStatus.current === "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 @@ -5,3 +5,4 @@ export * from "./Toggle.svelte";
export * from "./Slider.svelte";
export * from "./utils.svelte";
export * from "./Tree.svelte";
export * from "./Avatar.svelte";
16 changes: 16 additions & 0 deletions packages/melt/src/lib/components/Avatar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar, type AvatarProps } 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]>;
};

let { children, ...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 @@ -3,3 +3,4 @@ export { default as PinInput } from "./PinInput.svelte";
export { default as Tabs } from "./Tabs.svelte";
export { default as Slider } from "./Slider.svelte";
export { default as Popover } from "./Popover.svelte";
export { default as Avatar } from "./Avatar.svelte";