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
5 changes: 5 additions & 0 deletions .changeset/avatar-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@human-kit/ui': minor
---

Add the Avatar primitive with `Root`, `Image` and `Fallback`. The image loads off the screen first, and the `<img>` renders once it is there, thus a broken image icon never shows. The fallback shows while the image is not on the screen, at once for a failure or a missing `src`, and after `delay` milliseconds while a slow image loads. The root has `data-status` and `onStatusChange` for `loading`, `loaded` and `error`. The fallback takes the name of the image as a `role="img"`, and `loading="lazy"` starts the load when the avatar comes into view. `Avatar.Group` is a named `role="group"` with a `max`, and `Avatar.Count` says how many avatars are past it, as `+N` on the screen and "N more" for the screen reader.
5 changes: 5 additions & 0 deletions .changeset/breadcrumbs-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@human-kit/ui': minor
---

Add the Breadcrumbs primitive with `Root`, `List`, `Item`, `Link` and `Separator`. The root is a `<nav>` landmark named "Breadcrumb" in the locale of `LocaleProvider`, the list is an `<ol>` of `<li>`, and `current` gives the last link `aria-current="page"`. A link without an `href`, or with `disabled`, is a `<span>` out of the tab order. The separator is `aria-hidden`, in the item after the link, thus the list holds pages only. `maxItems` folds the middle of a long trail behind `Breadcrumbs.Ellipsis`, a button named "Show N more pages" that unfolds it and moves the focus to the first page that comes into view.
5 changes: 5 additions & 0 deletions .changeset/separator-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@human-kit/ui': minor
---

Add the Separator primitive: a `role="separator"` line with `aria-orientation="vertical"` for a vertical one, and `role="none"` with `decorative` for a line the content already makes. With a `value`, it is a window splitter: a tab stop with `aria-valuenow`, which the arrow keys, `Home`, `End`, `Enter` and a pointer drag move.
235 changes: 235 additions & 0 deletions docs/src/content/avatar/api.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
{
"component": "avatar",
"parts": [
{
"name": "Root",
"description": "The picture and its fallback. It holds the status of the image, and it has no role of its own.",
"props": [
{
"name": "children",
"type": "Snippet",
"required": false,
"default": null,
"description": "`Avatar.Image` and `Avatar.Fallback`."
},
{
"name": "class",
"type": "string",
"required": false,
"default": "''",
"description": "The CSS class names of the element."
},
{
"name": "element",
"type": "HTMLSpanElement | null",
"required": false,
"default": "null",
"description": "The root element. Use `bind:element` to read it."
},
{
"name": "onStatusChange",
"type": "(status: AvatarStatus) => void",
"required": false,
"default": null,
"description": "Called when the image starts to load, loads, or fails."
}
],
"dataAttributes": [
{
"name": "data-avatar-root",
"description": "Present on the element."
},
{
"name": "data-index",
"description": "The place of the avatar in its group, from 0. Absent outside a group."
},
{
"name": "data-status",
"description": "`loading`, `loaded` or `error`."
}
]
},
{
"name": "Image",
"description": "The picture. It loads the image off the screen first, and it renders the `<img>` once the image is there.",
"props": [
{
"name": "src",
"type": "string | null",
"required": false,
"default": null,
"description": "The address of the image. Without it, the fallback shows."
},
{
"name": "loading",
"type": "'eager' | 'lazy'",
"required": false,
"default": "'eager'",
"description": "`lazy` starts the load when the avatar comes into view, and not on mount. Use it on a long\nlist of avatars, thus the ones below the fold do not all load at once."
},
{
"name": "alt",
"type": "string",
"required": true,
"default": null,
"description": "The text in place of the image. Give the name of the person or the thing. Give an empty\nstring when the name is beside the avatar already, thus a screen reader does not read it\ntwice."
},
{
"name": "class",
"type": "string",
"required": false,
"default": "''",
"description": "The CSS class names of the element."
},
{
"name": "element",
"type": "HTMLImageElement | null",
"required": false,
"default": "null",
"description": "The image element, once the image is on the screen. Use `bind:element` to read it."
}
],
"dataAttributes": [
{
"name": "data-avatar-image",
"description": "Present on the element."
}
]
},
{
"name": "Fallback",
"description": "What shows in place of the image, while the image is not on the screen, named with its `alt`. `delay` holds it back while the image loads.",
"props": [
{
"name": "children",
"type": "Snippet",
"required": false,
"default": null,
"description": "The content in place of the image, such as initials or an icon."
},
{
"name": "class",
"type": "string",
"required": false,
"default": "''",
"description": "The CSS class names of the element."
},
{
"name": "delay",
"type": "number",
"required": false,
"default": "0",
"description": "The time in milliseconds before the fallback shows while the image loads. A fallback that\nflashes before a fast image is noise: 300 or 600 hides it on a good connection. It shows at\nonce when the image fails, or when there is no image."
},
{
"name": "element",
"type": "HTMLSpanElement | null",
"required": false,
"default": "null",
"description": "The fallback element, while it is on the screen. Use `bind:element` to read it."
}
],
"dataAttributes": [
{
"name": "data-avatar-fallback",
"description": "Present on the element."
},
{
"name": "data-status",
"description": "`loading` or `error`: the fallback is not on the screen while the image is."
}
]
},
{
"name": "Group",
"description": "A row of avatars, as a `role=\"group\"` named with `aria-label`. `max` limits the avatars on the screen.",
"props": [
{
"name": "children",
"type": "Snippet",
"required": false,
"default": null,
"description": "The avatars, and an `Avatar.Count` for the ones past `max`."
},
{
"name": "class",
"type": "string",
"required": false,
"default": "''",
"description": "The CSS class names of the element."
},
{
"name": "max",
"type": "number",
"required": false,
"default": null,
"description": "The count of avatars on the screen. The ones past it render nothing, and `Avatar.Count`\nsays how many they are. Without it, every avatar is on the screen."
},
{
"name": "aria-label",
"type": "string",
"required": false,
"default": null,
"description": "The name of the group, such as \"Assignees\". A group with no name is a group a screen\nreader cannot tell apart from the content around it."
},
{
"name": "element",
"type": "HTMLDivElement | null",
"required": false,
"default": "null",
"description": "The group element. Use `bind:element` to read it."
}
],
"dataAttributes": [
{
"name": "data-avatar-group",
"description": "Present on the element."
},
{
"name": "data-overflow",
"description": "Present while the group has more avatars than it shows."
}
]
},
{
"name": "Count",
"description": "The count of avatars past the `max` of the group, as `+N`, and \"N more\" for the screen reader. It renders only while there are some.",
"props": [
{
"name": "children",
"type": "Snippet<[AvatarCountState]>",
"required": false,
"default": null,
"description": "The content in place of `+N`. It gets the overflow, the count and the limit."
},
{
"name": "class",
"type": "string",
"required": false,
"default": "''",
"description": "The CSS class names of the element."
},
{
"name": "aria-label",
"type": "string",
"required": false,
"default": null,
"description": "The name for the screen reader. Without it, \"N more\" in the locale of `LocaleProvider`."
},
{
"name": "element",
"type": "HTMLSpanElement | null",
"required": false,
"default": "null",
"description": "The count element, while it is on the screen. Use `bind:element` to read it."
}
],
"dataAttributes": [
{
"name": "data-avatar-count",
"description": "Present on the element."
}
]
}
]
}
39 changes: 39 additions & 0 deletions docs/src/content/avatar/demos/group.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { Avatar } from '@human-kit/ui';

const people = [
['Ada Lovelace', '#c7d2fe', '#4338ca'],
['Grace Hopper', '#fde68a', '#b45309'],
['Katherine Johnson', '#bbf7d0', '#15803d'],
['Mary Jackson', '#fecaca', '#b91c1c'],
['Dorothy Vaughan', '#e9d5ff', '#7e22ce']
] as const;

// Drawn portraits, thus the demo needs no file and no person.
function portrait(back: string, front: string) {
return (
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect width="80" height="80" fill="${back}"/><circle cx="40" cy="32" r="14" fill="${front}"/><path d="M12 80c0-16 12-26 28-26s28 10 28 26z" fill="${front}"/></svg>`
)
);
}

const avatarClass =
'inline-flex size-10 items-center justify-center overflow-hidden rounded-full bg-neutral-200 ring-2 ring-white dark:bg-neutral-800 dark:ring-neutral-950';
</script>

<Avatar.Group max={3} aria-label="Assignees" class="flex -space-x-2">
{#each people as [name, back, front] (name)}
<Avatar.Root class={avatarClass}>
<Avatar.Image src={portrait(back, front)} alt={name} class="size-full object-cover" />
<Avatar.Fallback delay={600} class="text-xs font-medium">
{name
.split(' ')
.map((part) => part[0])
.join('')}
</Avatar.Fallback>
</Avatar.Root>
{/each}
<Avatar.Count class="{avatarClass} text-xs font-medium text-neutral-700 dark:text-neutral-200" />
</Avatar.Group>
31 changes: 31 additions & 0 deletions docs/src/content/avatar/demos/hero.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
import { Avatar } from '@human-kit/ui';

// A drawn portrait, thus the demo needs no file and no person.
const portrait =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect width="80" height="80" fill="#c7d2fe"/><circle cx="40" cy="32" r="14" fill="#4338ca"/><path d="M12 80c0-16 12-26 28-26s28 10 28 26z" fill="#4338ca"/></svg>'
);

const rootClass =
'inline-flex size-12 items-center justify-center overflow-hidden rounded-full bg-neutral-200 align-middle dark:bg-neutral-800';
const fallbackClass = 'text-sm font-medium text-neutral-700 dark:text-neutral-200';
</script>

<div class="flex items-center gap-4">
<Avatar.Root class={rootClass}>
<Avatar.Image src={portrait} alt="Ada Lovelace" class="size-full object-cover" />
<Avatar.Fallback delay={600} class={fallbackClass}>AL</Avatar.Fallback>
</Avatar.Root>

<Avatar.Root class={rootClass}>
<Avatar.Image src="/no-such-portrait.jpg" alt="Grace Hopper" class="size-full object-cover" />
<Avatar.Fallback class={fallbackClass}>GH</Avatar.Fallback>
</Avatar.Root>

<Avatar.Root class={rootClass}>
<Avatar.Image src={null} alt="Katherine Johnson" />
<Avatar.Fallback class={fallbackClass}>KJ</Avatar.Fallback>
</Avatar.Root>
</div>
32 changes: 32 additions & 0 deletions docs/src/content/avatar/demos/status.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { Avatar, type AvatarStatus } from '@human-kit/ui';

const portrait =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect width="80" height="80" fill="#fde68a"/><circle cx="40" cy="32" r="14" fill="#b45309"/><path d="M12 80c0-16 12-26 28-26s28 10 28 26z" fill="#b45309"/></svg>'
);

let status = $state<AvatarStatus>('loading');
let src = $state<string | null>(portrait);

const buttonClass =
'border border-neutral-300 px-3 py-1 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800';
</script>

<div class="flex flex-wrap items-center gap-4 text-sm text-neutral-900 dark:text-white">
<Avatar.Root
class="inline-flex size-12 items-center justify-center overflow-hidden rounded-full bg-neutral-200 align-middle data-[status=error]:bg-red-100 dark:bg-neutral-800 dark:data-[status=error]:bg-red-950"
onStatusChange={(next) => (status = next)}
>
<Avatar.Image {src} alt="Mary Jackson" class="size-full object-cover" />
<Avatar.Fallback delay={600} class="font-medium text-neutral-700 dark:text-neutral-200">
MJ
</Avatar.Fallback>
</Avatar.Root>
<span class="w-28 tabular-nums">status: {status}</span>
<button type="button" class={buttonClass} onclick={() => (src = portrait)}>Good source</button>
<button type="button" class={buttonClass} onclick={() => (src = '/no-such-portrait.jpg')}>
Bad source
</button>
</div>
Loading
Loading