diff --git a/.changeset/floppy-bananas-wonder.md b/.changeset/floppy-bananas-wonder.md new file mode 100644 index 0000000000..2b977e646a --- /dev/null +++ b/.changeset/floppy-bananas-wonder.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": minor +--- + +**Avatar**: added `asChild` prop diff --git a/.changeset/neat-eggs-clap.md b/.changeset/neat-eggs-clap.md new file mode 100644 index 0000000000..f9aae7863b --- /dev/null +++ b/.changeset/neat-eggs-clap.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": minor +--- + +**AvatarStack**: New component diff --git a/.changeset/sour-beers-knock.md b/.changeset/sour-beers-knock.md new file mode 100644 index 0000000000..09956e54d7 --- /dev/null +++ b/.changeset/sour-beers-knock.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-css": minor +--- + +**avatar-stack**: New component diff --git a/apps/www/app/app.css b/apps/www/app/app.css index dcde17b6d5..619c1b1f8d 100644 --- a/apps/www/app/app.css +++ b/apps/www/app/app.css @@ -11,11 +11,9 @@ --circle-wipe-progress: 150vmax; } } - /* biome-ignore lint/correctness/noUnknownTypeSelector: new feature */ ::view-transition-old(root) { animation: none; } - /* biome-ignore lint/correctness/noUnknownTypeSelector: new feature */ &::view-transition-new(root) { mask-image: radial-gradient(circle var(--circle-wipe-progress) at var(--_theme-x, 50%) var(--_theme-y, 50%), black 70%, transparent 100%); animation: 0.6s ease-in both --circle-wipe; diff --git a/apps/www/app/content/components/avatar-stack/avatar.stories.tsx b/apps/www/app/content/components/avatar-stack/avatar.stories.tsx new file mode 100644 index 0000000000..5cd43a310f --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/avatar.stories.tsx @@ -0,0 +1,22 @@ +import { + Avatar, + AvatarStack, + Checkbox, + Label, + Tooltip, +} from '@digdir/designsystemet-react'; +import { BriefcaseIcon } from '@navikt/aksel-icons'; + +export const Preview = () => ( + + + + + + + + + md + + +); diff --git a/apps/www/app/content/components/avatar-stack/en/accessibility.mdx b/apps/www/app/content/components/avatar-stack/en/accessibility.mdx new file mode 100644 index 0000000000..ac2c20cd8d --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/en/accessibility.mdx @@ -0,0 +1,5 @@ + +We are working to improve the accessibility documentation for this component. If you have questions or see something that should be prioritised, please contact us on [Github](https://github.com/digdir/designsystemet/issues/new?template=1bug_report.yml) or [Slack](https://designsystemet.no/slack). + + +## How to describe `AvatarStack` for screen readers diff --git a/apps/www/app/content/components/avatar-stack/en/code.mdx b/apps/www/app/content/components/avatar-stack/en/code.mdx new file mode 100644 index 0000000000..7296011766 --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/en/code.mdx @@ -0,0 +1,55 @@ + + + + +## Usage + +```tsx +import { AvatarStack, Avatar } from '@digdir/designsystemet-react'; + + + ON + + + + + +``` + +## Examples + +### Customizability + +### "Additional avatars" + +### Sizing + +### Tooltip + +### Link + + +## HTML + +AvatarStack uses the class name `ds-avatar-stack` on a `
`. + +```html + + + + +ON + + + + + + + + + + +Ola Nordmann +``` + +## CSS variables and data attributes diff --git a/apps/www/app/content/components/avatar-stack/en/overview.mdx b/apps/www/app/content/components/avatar-stack/en/overview.mdx new file mode 100644 index 0000000000..95016e57da --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/en/overview.mdx @@ -0,0 +1,16 @@ + + +**Use `AvatarStack` when** + +**Avoid `AvatarStack` when** + + +## Examples + +### Sizing + +### Variants + +## Guidelines + +## Text diff --git a/apps/www/app/content/components/avatar-stack/metadata.json b/apps/www/app/content/components/avatar-stack/metadata.json new file mode 100644 index 0000000000..113445462a --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/metadata.json @@ -0,0 +1,12 @@ +{ + "no": { + "title": "AvatarStack", + "subtitle": "`AvatarStack` stabler en samling `Avatar` elementer" + }, + "en": { + "title": "AvatarStack", + "subtitle": "`AvatarStack` stacks a collection of `Avatar` elements" + }, + "image": "Avatar.svg", + "cssFile": "avatar-stack.css" +} diff --git a/apps/www/app/content/components/avatar-stack/no/accessibility.mdx b/apps/www/app/content/components/avatar-stack/no/accessibility.mdx new file mode 100644 index 0000000000..28e84fb52a --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/no/accessibility.mdx @@ -0,0 +1,6 @@ + +Vi jobber med å forbedre dokumentasjonen for tilgjengelighet på denne komponenten. Har du spørsmål eller ser noe som bør prioriteres, ta gjerne kontakt med oss på [Github](https://github.com/digdir/designsystemet/issues/new?template=1bug_report.yml) eller [Slack](https://designsystemet.no/slack). + + +## Hvordan beskrive `Avatar` for skjermlesere + diff --git a/apps/www/app/content/components/avatar-stack/no/code.mdx b/apps/www/app/content/components/avatar-stack/no/code.mdx new file mode 100644 index 0000000000..eb9be1ccf1 --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/no/code.mdx @@ -0,0 +1,55 @@ + + + + +## Bruk + +```tsx +import { AvatarStack, Avatar } from '@digdir/designsystemet-react'; + + + ON + + + + + +``` + +## Eksempel + +### Customizability + +### "Additional avatars" + +### Størrelser + +### Tooltip + +### Link + + +## HTML + +AvatarStack bruker klassen `ds-avatar-stack` på en `
`. + +```html + + + + +ON + + + + + + + + + + +Ola Nordmann +``` + +## CSS variabler og data atributter \ No newline at end of file diff --git a/apps/www/app/content/components/avatar-stack/no/overview.mdx b/apps/www/app/content/components/avatar-stack/no/overview.mdx new file mode 100644 index 0000000000..8b6cdea58f --- /dev/null +++ b/apps/www/app/content/components/avatar-stack/no/overview.mdx @@ -0,0 +1,16 @@ + + +**Bruk `AvatarStack` når** + +**Unngå `AvatarStack` når** + + +## Eksempel + +### Størrelser + +### Varianter + +## Retningslinjer + +## tekst diff --git a/packages/css/src/avatar-stack.css b/packages/css/src/avatar-stack.css new file mode 100644 index 0000000000..bdec2d7f0e --- /dev/null +++ b/packages/css/src/avatar-stack.css @@ -0,0 +1,89 @@ +@property --captured-length { + syntax: ''; + inherits: true; + initial-value: 0px; +} + +.ds-avatar-stack { + --dsc-avatar-stack-size: var(--ds-size-12); + --dsc-avatar-stack-gap: 2px; + --dsc-avatar-stack-overlap: 50; + /*--dsc-avatar-count is only needed when the stack is expandable="fixed", to calculate a fixed width.*/ + --dsc-avatar-count: initial; + + --_gap: var(--dsc-avatar-stack-gap); + --_overlap: calc(((var(--dsc-avatar-stack-size) / 100) * var(--dsc-avatar-stack-overlap)) * -1); + + --captured-length: var(--_overlap); + display: flex; + align-items: center; + isolation: isolate; + padding-block: 1px; + width: max-content; + transition: --captured-length 0.2s ease; + + &:focus-visible { + @composes ds-focus--visible from './base.css'; + } + + &[data-expandable] { + &:hover, + &:focus-visible { + --captured-length: var(--_gap); + } + } + + &[data-expandable='fixed'] { + width: calc(var(--dsc-avatar-stack-size) * var(--dsc-avatar-count) + var(--_overlap) * (var(--dsc-avatar-count) - 1)); + } + + &:empty { + display: none; + } + + &[data-suffix]::after { + content: attr(data-suffix); + place-self: center; + margin-left: 1ch; + font-size: max(1rem, calc(var(--dsc-avatar-stack-size) * 0.4)); + } + + .ds-avatar { + --dsc-avatar-size: var(--dsc-avatar-stack-size); + --_font-size: max(0.75rem, calc(var(--dsc-avatar-stack-size) * 0.5)); + mask: radial-gradient(50% 50% at calc(150% + var(--captured-length)), transparent calc(100% - 1px + var(--_gap)), #000 calc(100% + var(--_gap))); + &[data-variant='square'] { + mask: linear-gradient(to right, #000 calc(100% + var(--captured-length) - var(--_gap)), transparent calc(100% + var(--captured-length) - var(--_gap))); + } + + &:not(:first-child) { + margin-left: var(--captured-length); + } + + &:nth-last-child(1 of .ds-avatar) { + mask: none; + } + + @supports not selector(:nth-last-child(1 of .ds-avatar)) { + /*Note: This selector will remove all masks if there are tooltips on avatars but will only apply to chromium browsers older than 2023*/ + &:not(:has(+ .ds-avatar)) { + mask: none; + } + } + + /*we have to override the default font-size for the two ways you can add initials to avatar */ + &[data-initials]:empty::before { + font-size: var(--_font-size); + } + + > span { + font-size: var(--_font-size); + } + &:has(:focus-visible), + &:focus-visible { + mask: none; + @composes ds-focus--visible from './base.css'; + z-index: 1; + } + } +} diff --git a/packages/css/src/index.css b/packages/css/src/index.css index ce5b8887ba..23fb4ab15d 100644 --- a/packages/css/src/index.css +++ b/packages/css/src/index.css @@ -37,5 +37,6 @@ @import url('./breadcrumbs.css') layer(ds.components); @import url('./badge.css') layer(ds.components); @import url('./avatar.css') layer(ds.components); +@import url('./avatar-stack.css') layer(ds.components); @import url('./suggestion.css') layer(ds.components); @import url('./combobox.css') layer(ds.components); diff --git a/packages/react/src/components/avatar-stack/avatar-stack.mdx b/packages/react/src/components/avatar-stack/avatar-stack.mdx new file mode 100644 index 0000000000..db984ccac5 --- /dev/null +++ b/packages/react/src/components/avatar-stack/avatar-stack.mdx @@ -0,0 +1,31 @@ +import { Meta, Canvas, Controls, Primary } from '@storybook/addon-docs/blocks'; +import * as AvatarStackStories from './avatar-stack.stories'; + + + +# AvatarStack + +`AvatarStack` er en komponent som stabler `Avatar`. + + + + +## Interactive playground + + +## expandable + + +## data-size with differen units + + +## Square + + +## Tooltip + + +## Link + + + diff --git a/packages/react/src/components/avatar-stack/avatar-stack.stories.tsx b/packages/react/src/components/avatar-stack/avatar-stack.stories.tsx new file mode 100644 index 0000000000..1834e789af --- /dev/null +++ b/packages/react/src/components/avatar-stack/avatar-stack.stories.tsx @@ -0,0 +1,478 @@ +import { BriefcaseIcon } from '@navikt/aksel-icons'; +import type { Meta, StoryFn } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Avatar, AvatarStack, Checkbox, Label, Tooltip } from '../'; + +type Story = StoryFn; + +const meta: Meta = { + title: 'Komponenter/AvatarStack', + component: AvatarStack, + parameters: { + layout: 'fullscreen', + }, + args: { + 'aria-label': 'Test av aria label', + }, +}; + +export default meta; + +export const Preview: Story = (args) => ( + + + + + + + + + md + + +); + +export const Expandable: Story = (args) => ( +
+
+ expandable + + + + + + + + + + + + + + +
+
+ expandable="fixed" + + + + + + + + + + + + + + +
+
+ not expandable + + + + + + + + + + + +
+
+); +Expandable.args = { + gap: '4px', +}; + +export const DataSize: Story = (args) => ( + <> +
+ avatarSize='var(--ds-size-12)' + + + + + + + + sm + + + + + + + + + + md + + + + + + + + + + lg + + +
+
+ avatarSize='3em' + + + + + + + + sm + + + + + + + + + + md + + + + + + + + + + lg + + +
+
+ avatarSize='3rem' + + + + + + + + sm + + + + + + + + + + md + + + + + + + + + + lg + + +
+ +); + +export const ShapeVariants: Story = (args) => ( + + + + + + + + + + + + + + + +); +ShapeVariants.args = { + overlap: 50, + expandable: 'fixed', +}; + +export const WithTooltip: Story = (args) => ( +
+
+ expandable + + + + + + + + + + + + + + + + + + + + + + +
+
+ not expandable + + + + + + + + + + + + + + + + + + + + + + +
+
+); + +export const WithTooltipAndLink: Story = (args) => ( +
+
+ Link expandable + + + + + + + + + + + + + + + + + + + + + + +
+
+ Link + Tooltip + + + + + + + + + + + + + + + + + + + + + + + + + BR + + + +
+
+); + +export const Playground: Story = () => { + const [expandable, setExpandable] = useState(undefined); + const [square, setSquare] = useState(false); + const [size, setSize] = useState(64); + const [overlap, setOverlap] = useState(50); + const [gap, setGap] = useState(2); + const labelStyle = { + display: 'flex', + flexDirection: 'column', + gap: 'var(--ds-size-2)', + accentColor: 'var(--ds-color-base-default)', + } as const; + + return ( +
+
+
+ setExpandable((prev) => (prev ? undefined : true))} + /> + setSquare((prev) => !prev)} + /> +
+ + + +
+ + + + + + + + + + md + + + + + + + + +
+ ); +}; diff --git a/packages/react/src/components/avatar-stack/avatar-stack.tsx b/packages/react/src/components/avatar-stack/avatar-stack.tsx new file mode 100644 index 0000000000..4ccf90a4a7 --- /dev/null +++ b/packages/react/src/components/avatar-stack/avatar-stack.tsx @@ -0,0 +1,92 @@ +import cl from 'clsx/lite'; +import type { HTMLAttributes } from 'react'; +import { Children, forwardRef } from 'react'; + +/* @TODO: + * a11y + * rightAligned? + * vertical?? + * support Badge? + * design for +n indicator + */ + +export type AvatarStackProps = { + /** + * Adjusts gap-mask between avatars in the stack. Must be a valid css length value (px, em, rem, var(--ds-size-1) etc.) + * @default 2px + */ + gap?: string; + /** + * Control the size of the avatars. Must be a valid css length value (px, em, rem, var(--ds-size-12) etc.) + * @default 'var(--ds-size-12)' + */ + avatarSize?: string; + /** + * A number which represents the percentage value of how much avatars should overlap. + * @default 50 + */ + overlap?: number; + /** + * Text to the right of the avatars to show a number representing additional avatars not shown such as '+5'". + */ + suffix?: string; + /** + * Expand on hover to show full avatars. + * 'fixed': AvatarStack physical width does not change when avatars are expanded. + * @default false + */ + expandable?: 'fixed' | boolean; +} & HTMLAttributes; + +/** + * Use `AvatarStack` to constrain Avatars into a stack. + * + * @example + * + * + * + * + * + * + * + * + * + */ +export const AvatarStack = forwardRef( + function AvatarStack( + { + className, + gap, + suffix, + avatarSize, + overlap, + expandable, + children, + ...rest + }, + ref, + ) { + const style = { + ...(rest.style || {}), + '--dsc-avatar-stack-gap': gap !== undefined ? `${gap}` : undefined, + '--dsc-avatar-stack-size': avatarSize ? `${avatarSize}` : undefined, + '--dsc-avatar-stack-overlap': + overlap !== undefined ? `${overlap}` : undefined, + '--dsc-avatar-count': + expandable === 'fixed' ? Children.count(children) : undefined, + } as React.CSSProperties; + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/react/src/components/avatar/avatar.tsx b/packages/react/src/components/avatar/avatar.tsx index 8a25177e9d..df2bb3d748 100644 --- a/packages/react/src/components/avatar/avatar.tsx +++ b/packages/react/src/components/avatar/avatar.tsx @@ -31,6 +31,11 @@ export type AvatarProps = MergeRight< * Initials to display inside the avatar. */ initials?: string; + /** + * Change the default rendered element for the one passed as a child, merging their props and behavior. + * @default false + */ + asChild?: boolean; /** * Image, icon or initials to display inside the avatar. * @@ -63,16 +68,18 @@ export const Avatar = forwardRef(function Avatar( className, children, initials, + asChild, ...rest }, ref, ) { + const OuterComponent = asChild ? Slot : 'span'; const useSlot = children && typeof children !== 'string'; const textChild = children && typeof children === 'string'; const Component = useSlot ? Slot : Fragment; return ( - (function Avatar( {textChild ? {children} : children} - + ); }); diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 58fbcfa7ce..20541d27a1 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -4,6 +4,9 @@ export { Alert } from './alert/alert'; export type { AvatarProps } from './avatar/avatar'; export { Avatar } from './avatar/avatar'; +export type { AvatarStackProps } from './avatar-stack/avatar-stack'; +export { AvatarStack } from './avatar-stack/avatar-stack'; + export type { BadgePositionProps, BadgeProps } from './badge'; export { Badge, BadgePosition } from './badge';