@dillingerstaffing/strand-svelte 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/css/strand-ui.css +2583 -0
- package/dist/index.js +4154 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/components/Alert/Alert.svelte +32 -0
- package/src/components/Alert/Alert.test.ts +64 -0
- package/src/components/Alert/index.ts +2 -0
- package/src/components/Avatar/Avatar.svelte +40 -0
- package/src/components/Avatar/Avatar.test.ts +55 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.svelte +41 -0
- package/src/components/Badge/Badge.test.ts +55 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.svelte +29 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +66 -0
- package/src/components/Breadcrumb/index.ts +2 -0
- package/src/components/Button/Button.svelte +55 -0
- package/src/components/Button/Button.test.ts +110 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Card/Card.svelte +17 -0
- package/src/components/Card/Card.test.ts +32 -0
- package/src/components/Card/index.ts +2 -0
- package/src/components/Checkbox/Checkbox.svelte +62 -0
- package/src/components/Checkbox/Checkbox.test.ts +67 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/CodeBlock/CodeBlock.svelte +14 -0
- package/src/components/CodeBlock/CodeBlock.test.ts +36 -0
- package/src/components/CodeBlock/index.ts +2 -0
- package/src/components/Container/Container.svelte +14 -0
- package/src/components/Container/Container.test.ts +23 -0
- package/src/components/Container/index.ts +2 -0
- package/src/components/DataReadout/DataReadout.svelte +19 -0
- package/src/components/DataReadout/DataReadout.test.ts +35 -0
- package/src/components/DataReadout/index.ts +2 -0
- package/src/components/Dialog/Dialog.svelte +131 -0
- package/src/components/Dialog/Dialog.test.ts +77 -0
- package/src/components/Dialog/index.ts +2 -0
- package/src/components/Divider/Divider.svelte +36 -0
- package/src/components/Divider/Divider.test.ts +34 -0
- package/src/components/Divider/index.ts +2 -0
- package/src/components/FormField/FormField.svelte +39 -0
- package/src/components/FormField/FormField.test.ts +58 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/Grid/Grid.svelte +13 -0
- package/src/components/Grid/Grid.test.ts +32 -0
- package/src/components/Grid/index.ts +2 -0
- package/src/components/Input/Input.svelte +41 -0
- package/src/components/Input/Input.test.ts +64 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Link/Link.svelte +17 -0
- package/src/components/Link/Link.test.ts +28 -0
- package/src/components/Link/index.ts +2 -0
- package/src/components/Nav/Nav.svelte +69 -0
- package/src/components/Nav/Nav.test.ts +75 -0
- package/src/components/Nav/index.ts +2 -0
- package/src/components/Progress/Progress.svelte +78 -0
- package/src/components/Progress/Progress.test.ts +58 -0
- package/src/components/Progress/index.ts +2 -0
- package/src/components/Radio/Radio.svelte +46 -0
- package/src/components/Radio/Radio.test.ts +52 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Section/Section.svelte +17 -0
- package/src/components/Section/Section.test.ts +29 -0
- package/src/components/Section/index.ts +2 -0
- package/src/components/Select/Select.svelte +45 -0
- package/src/components/Select/Select.test.ts +59 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Skeleton/Skeleton.svelte +25 -0
- package/src/components/Skeleton/Skeleton.test.ts +44 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.svelte +37 -0
- package/src/components/Slider/Slider.test.ts +45 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Spinner/Spinner.svelte +15 -0
- package/src/components/Spinner/Spinner.test.ts +38 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/Stack/Stack.svelte +27 -0
- package/src/components/Stack/Stack.test.ts +46 -0
- package/src/components/Stack/index.ts +2 -0
- package/src/components/Switch/Switch.svelte +48 -0
- package/src/components/Switch/Switch.test.ts +61 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Table/Table.svelte +67 -0
- package/src/components/Table/Table.test.ts +88 -0
- package/src/components/Table/index.ts +2 -0
- package/src/components/Tabs/Tabs.svelte +89 -0
- package/src/components/Tabs/Tabs.test.ts +66 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Tag/Tag.svelte +33 -0
- package/src/components/Tag/Tag.test.ts +63 -0
- package/src/components/Tag/index.ts +2 -0
- package/src/components/Textarea/Textarea.svelte +53 -0
- package/src/components/Textarea/Textarea.test.ts +53 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/Toast/Toast.svelte +29 -0
- package/src/components/Toast/Toast.test.ts +60 -0
- package/src/components/Toast/ToastProvider.svelte +45 -0
- package/src/components/Toast/index.ts +5 -0
- package/src/components/Toast/useToast.ts +78 -0
- package/src/components/Tooltip/Tooltip.svelte +56 -0
- package/src/components/Tooltip/Tooltip.test.ts +50 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/index.ts +46 -0
- package/src/test-setup.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dillingerstaffing/strand-svelte",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Strand UI - Svelte component library built on the Strand Design Language",
|
|
5
|
+
"author": "Dillinger Staffing <engineering@dillingerstaffing.com> (https://dillingerstaffing.com)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["design-system", "ui-components", "svelte", "svelte4", "svelte5", "css-custom-properties", "design-tokens", "accessibility", "wcag", "aria", "component-library"],
|
|
8
|
+
"homepage": "https://dillingerstaffing.com/labs/strand",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/dillingerstaffing/strand",
|
|
12
|
+
"directory": "packages/strand-svelte"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/dillingerstaffing/strand/issues"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"svelte": "./dist/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"svelte": "./dist/index.js",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./css/strand-ui.css": "./dist/css/strand-ui.css"
|
|
29
|
+
},
|
|
30
|
+
"style": "./dist/css/strand-ui.css",
|
|
31
|
+
"files": ["dist/", "src/"],
|
|
32
|
+
"sideEffects": ["dist/css/*.css"],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "vite build && cp ../../HTML_REFERENCE.md ./HTML_REFERENCE.md",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"svelte": "^4.0.0 || ^5.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@dillingerstaffing/strand": "workspace:^"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
45
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
46
|
+
"@testing-library/svelte": "^5.2.0",
|
|
47
|
+
"jsdom": "^26.0.0",
|
|
48
|
+
"svelte": "^5.0.0",
|
|
49
|
+
"svelte-check": "^4.0.0",
|
|
50
|
+
"vite": "^6.0.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Visual status of the alert */
|
|
4
|
+
export let status: 'info' | 'success' | 'warning' | 'error' = 'info'
|
|
5
|
+
/** Show dismiss button */
|
|
6
|
+
export let dismissible: boolean = false
|
|
7
|
+
/** Called when dismiss button is clicked */
|
|
8
|
+
export let ondismiss: (() => void) | undefined = undefined
|
|
9
|
+
|
|
10
|
+
$: role = status === 'error' || status === 'warning' ? 'alert' : 'status'
|
|
11
|
+
|
|
12
|
+
$: classes = [
|
|
13
|
+
'strand-alert',
|
|
14
|
+
`strand-alert--${status}`,
|
|
15
|
+
].filter(Boolean).join(' ')
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<div class={classes} {role} {...$$restProps}>
|
|
19
|
+
<div class="strand-alert__content">
|
|
20
|
+
<slot />
|
|
21
|
+
</div>
|
|
22
|
+
{#if dismissible}
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
class="strand-alert__dismiss"
|
|
26
|
+
aria-label="Dismiss"
|
|
27
|
+
on:click={() => ondismiss?.()}
|
|
28
|
+
>
|
|
29
|
+
×
|
|
30
|
+
</button>
|
|
31
|
+
{/if}
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
4
|
+
import { render, fireEvent } from '@testing-library/svelte'
|
|
5
|
+
import Alert from './Alert.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Alert', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Alert)
|
|
10
|
+
const el = container.querySelector('.strand-alert')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-alert--info')
|
|
13
|
+
expect(el).toHaveAttribute('role', 'status')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies status classes', () => {
|
|
17
|
+
const statuses = ['info', 'success', 'warning', 'error'] as const
|
|
18
|
+
for (const status of statuses) {
|
|
19
|
+
const { container, unmount } = render(Alert, { props: { status } })
|
|
20
|
+
expect(container.querySelector('.strand-alert')).toHaveClass(`strand-alert--${status}`)
|
|
21
|
+
unmount()
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('uses alert role for error and warning', () => {
|
|
26
|
+
for (const status of ['error', 'warning'] as const) {
|
|
27
|
+
const { container, unmount } = render(Alert, { props: { status } })
|
|
28
|
+
expect(container.querySelector('.strand-alert')).toHaveAttribute('role', 'alert')
|
|
29
|
+
unmount()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('uses status role for info and success', () => {
|
|
34
|
+
for (const status of ['info', 'success'] as const) {
|
|
35
|
+
const { container, unmount } = render(Alert, { props: { status } })
|
|
36
|
+
expect(container.querySelector('.strand-alert')).toHaveAttribute('role', 'status')
|
|
37
|
+
unmount()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('shows dismiss button when dismissible', () => {
|
|
42
|
+
const { container } = render(Alert, { props: { dismissible: true } })
|
|
43
|
+
const btn = container.querySelector('.strand-alert__dismiss')
|
|
44
|
+
expect(btn).toBeInTheDocument()
|
|
45
|
+
expect(btn).toHaveAttribute('aria-label', 'Dismiss')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not show dismiss button by default', () => {
|
|
49
|
+
const { container } = render(Alert)
|
|
50
|
+
expect(container.querySelector('.strand-alert__dismiss')).not.toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('fires ondismiss callback', async () => {
|
|
54
|
+
const ondismiss = vi.fn()
|
|
55
|
+
const { container } = render(Alert, { props: { dismissible: true, ondismiss } })
|
|
56
|
+
await fireEvent.click(container.querySelector('.strand-alert__dismiss')!)
|
|
57
|
+
expect(ondismiss).toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('has content wrapper', () => {
|
|
61
|
+
const { container } = render(Alert)
|
|
62
|
+
expect(container.querySelector('.strand-alert__content')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Image URL */
|
|
4
|
+
export let src: string | undefined = undefined
|
|
5
|
+
/** Alt text for image */
|
|
6
|
+
export let alt: string = ''
|
|
7
|
+
/** Fallback initials (1-2 characters) */
|
|
8
|
+
export let initials: string = ''
|
|
9
|
+
/** Avatar size */
|
|
10
|
+
export let size: 'sm' | 'md' | 'lg' | 'xl' = 'md'
|
|
11
|
+
|
|
12
|
+
let imgError = false
|
|
13
|
+
|
|
14
|
+
$: showImage = src && !imgError
|
|
15
|
+
$: displayInitials = initials.slice(0, 2).toUpperCase()
|
|
16
|
+
|
|
17
|
+
$: classes = [
|
|
18
|
+
'strand-avatar',
|
|
19
|
+
`strand-avatar--${size}`,
|
|
20
|
+
].filter(Boolean).join(' ')
|
|
21
|
+
|
|
22
|
+
function handleError() {
|
|
23
|
+
imgError = true
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<div class={classes} role="img" aria-label={alt || displayInitials} {...$$restProps}>
|
|
28
|
+
{#if showImage}
|
|
29
|
+
<img
|
|
30
|
+
class="strand-avatar__img"
|
|
31
|
+
{src}
|
|
32
|
+
{alt}
|
|
33
|
+
on:error={handleError}
|
|
34
|
+
/>
|
|
35
|
+
{:else}
|
|
36
|
+
<span class="strand-avatar__initials" aria-hidden="true">
|
|
37
|
+
{displayInitials}
|
|
38
|
+
</span>
|
|
39
|
+
{/if}
|
|
40
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Avatar from './Avatar.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Avatar', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Avatar)
|
|
10
|
+
const el = container.querySelector('.strand-avatar')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-avatar--md')
|
|
13
|
+
expect(el).toHaveAttribute('role', 'img')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies size classes', () => {
|
|
17
|
+
const sizes = ['sm', 'md', 'lg', 'xl'] as const
|
|
18
|
+
for (const size of sizes) {
|
|
19
|
+
const { container, unmount } = render(Avatar, { props: { size } })
|
|
20
|
+
expect(container.querySelector('.strand-avatar')).toHaveClass(`strand-avatar--${size}`)
|
|
21
|
+
unmount()
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('shows initials when no src', () => {
|
|
26
|
+
const { container } = render(Avatar, { props: { initials: 'AB' } })
|
|
27
|
+
const span = container.querySelector('.strand-avatar__initials')
|
|
28
|
+
expect(span).toBeInTheDocument()
|
|
29
|
+
expect(span).toHaveTextContent('AB')
|
|
30
|
+
expect(span).toHaveAttribute('aria-hidden', 'true')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('truncates initials to 2 characters', () => {
|
|
34
|
+
const { container } = render(Avatar, { props: { initials: 'abc' } })
|
|
35
|
+
expect(container.querySelector('.strand-avatar__initials')).toHaveTextContent('AB')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders image when src is provided', () => {
|
|
39
|
+
const { container } = render(Avatar, { props: { src: 'test.jpg', alt: 'Test' } })
|
|
40
|
+
const img = container.querySelector('.strand-avatar__img')
|
|
41
|
+
expect(img).toBeInTheDocument()
|
|
42
|
+
expect(img).toHaveAttribute('src', 'test.jpg')
|
|
43
|
+
expect(img).toHaveAttribute('alt', 'Test')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('sets aria-label from alt text', () => {
|
|
47
|
+
const { container } = render(Avatar, { props: { alt: 'User Name' } })
|
|
48
|
+
expect(container.querySelector('.strand-avatar')).toHaveAttribute('aria-label', 'User Name')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('sets aria-label from initials when no alt', () => {
|
|
52
|
+
const { container } = render(Avatar, { props: { initials: 'JD' } })
|
|
53
|
+
expect(container.querySelector('.strand-avatar')).toHaveAttribute('aria-label', 'JD')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Badge display mode */
|
|
4
|
+
export let variant: 'dot' | 'count' = 'count'
|
|
5
|
+
/** Color status */
|
|
6
|
+
export let status: 'default' | 'teal' | 'blue' | 'amber' | 'red' = 'default'
|
|
7
|
+
/** Number to display (count variant only) */
|
|
8
|
+
export let count: number | undefined = undefined
|
|
9
|
+
/** Maximum count before showing "N+" */
|
|
10
|
+
export let maxCount: number = 99
|
|
11
|
+
|
|
12
|
+
$: displayValue = variant === 'count'
|
|
13
|
+
? (count != null && count > maxCount ? `${maxCount}+` : count)
|
|
14
|
+
: null
|
|
15
|
+
|
|
16
|
+
$: ariaLabel = variant === 'dot'
|
|
17
|
+
? 'Status indicator'
|
|
18
|
+
: count != null
|
|
19
|
+
? `${count} notifications`
|
|
20
|
+
: undefined
|
|
21
|
+
|
|
22
|
+
$: badgeClasses = [
|
|
23
|
+
'strand-badge__indicator',
|
|
24
|
+
`strand-badge--${variant}`,
|
|
25
|
+
`strand-badge--${status}`,
|
|
26
|
+
].filter(Boolean).join(' ')
|
|
27
|
+
|
|
28
|
+
$: hasChildren = $$slots.default
|
|
29
|
+
$: wrapperClasses = hasChildren
|
|
30
|
+
? ['strand-badge'].filter(Boolean).join(' ')
|
|
31
|
+
: ['strand-badge', 'strand-badge--inline'].filter(Boolean).join(' ')
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<span class={wrapperClasses} {...$$restProps}>
|
|
35
|
+
{#if hasChildren}
|
|
36
|
+
<slot />
|
|
37
|
+
{/if}
|
|
38
|
+
<span class={badgeClasses} aria-label={ariaLabel} role="status">
|
|
39
|
+
{#if displayValue != null}{displayValue}{/if}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Badge from './Badge.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Badge', () => {
|
|
8
|
+
it('renders with default classes (inline, no children)', () => {
|
|
9
|
+
const { container } = render(Badge)
|
|
10
|
+
const el = container.querySelector('.strand-badge')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-badge--inline')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('shows count variant indicator', () => {
|
|
16
|
+
const { container } = render(Badge, { props: { count: 5 } })
|
|
17
|
+
const indicator = container.querySelector('.strand-badge__indicator')
|
|
18
|
+
expect(indicator).toHaveClass('strand-badge--count')
|
|
19
|
+
expect(indicator).toHaveTextContent('5')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('caps count at maxCount', () => {
|
|
23
|
+
const { container } = render(Badge, { props: { count: 150, maxCount: 99 } })
|
|
24
|
+
const indicator = container.querySelector('.strand-badge__indicator')
|
|
25
|
+
expect(indicator).toHaveTextContent('99+')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders dot variant', () => {
|
|
29
|
+
const { container } = render(Badge, { props: { variant: 'dot' } })
|
|
30
|
+
const indicator = container.querySelector('.strand-badge__indicator')
|
|
31
|
+
expect(indicator).toHaveClass('strand-badge--dot')
|
|
32
|
+
expect(indicator).toHaveAttribute('aria-label', 'Status indicator')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('applies status classes', () => {
|
|
36
|
+
const statuses = ['default', 'teal', 'blue', 'amber', 'red'] as const
|
|
37
|
+
for (const status of statuses) {
|
|
38
|
+
const { container, unmount } = render(Badge, { props: { status } })
|
|
39
|
+
expect(container.querySelector('.strand-badge__indicator')).toHaveClass(`strand-badge--${status}`)
|
|
40
|
+
unmount()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('has role="status" on indicator', () => {
|
|
45
|
+
const { container } = render(Badge, { props: { count: 3 } })
|
|
46
|
+
const indicator = container.querySelector('.strand-badge__indicator')
|
|
47
|
+
expect(indicator).toHaveAttribute('role', 'status')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('sets notifications aria-label for count', () => {
|
|
51
|
+
const { container } = render(Badge, { props: { count: 7 } })
|
|
52
|
+
const indicator = container.querySelector('.strand-badge__indicator')
|
|
53
|
+
expect(indicator).toHaveAttribute('aria-label', '7 notifications')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
export interface BreadcrumbItem {
|
|
4
|
+
label: string
|
|
5
|
+
href?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Breadcrumb path items; last item is treated as current page */
|
|
9
|
+
export let items: BreadcrumbItem[] = []
|
|
10
|
+
/** Separator character between items */
|
|
11
|
+
export let separator: string = '/'
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<nav aria-label="Breadcrumb" class="strand-breadcrumb" {...$$restProps}>
|
|
15
|
+
<ol class="strand-breadcrumb__list">
|
|
16
|
+
{#each items as item, index (item.label + index)}
|
|
17
|
+
<li class="strand-breadcrumb__item">
|
|
18
|
+
{#if index > 0}
|
|
19
|
+
<span class="strand-breadcrumb__separator" aria-hidden="true">{separator}</span>
|
|
20
|
+
{/if}
|
|
21
|
+
{#if index === items.length - 1}
|
|
22
|
+
<span class="strand-breadcrumb__current" aria-current="page">{item.label}</span>
|
|
23
|
+
{:else}
|
|
24
|
+
<a href={item.href} class="strand-breadcrumb__link">{item.label}</a>
|
|
25
|
+
{/if}
|
|
26
|
+
</li>
|
|
27
|
+
{/each}
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Breadcrumb from './Breadcrumb.svelte'
|
|
6
|
+
|
|
7
|
+
const testItems = [
|
|
8
|
+
{ label: 'Home', href: '/' },
|
|
9
|
+
{ label: 'Products', href: '/products' },
|
|
10
|
+
{ label: 'Widget' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('Breadcrumb', () => {
|
|
14
|
+
it('renders with nav and aria-label', () => {
|
|
15
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
16
|
+
const nav = container.querySelector('nav')
|
|
17
|
+
expect(nav).toBeInTheDocument()
|
|
18
|
+
expect(nav).toHaveAttribute('aria-label', 'Breadcrumb')
|
|
19
|
+
expect(nav).toHaveClass('strand-breadcrumb')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders items as list', () => {
|
|
23
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
24
|
+
const list = container.querySelector('.strand-breadcrumb__list')
|
|
25
|
+
expect(list).toBeInTheDocument()
|
|
26
|
+
const items = container.querySelectorAll('.strand-breadcrumb__item')
|
|
27
|
+
expect(items.length).toBe(3)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders links for non-last items', () => {
|
|
31
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
32
|
+
const links = container.querySelectorAll('.strand-breadcrumb__link')
|
|
33
|
+
expect(links.length).toBe(2)
|
|
34
|
+
expect(links[0]).toHaveAttribute('href', '/')
|
|
35
|
+
expect(links[0]).toHaveTextContent('Home')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders last item as current page', () => {
|
|
39
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
40
|
+
const current = container.querySelector('.strand-breadcrumb__current')
|
|
41
|
+
expect(current).toBeInTheDocument()
|
|
42
|
+
expect(current).toHaveAttribute('aria-current', 'page')
|
|
43
|
+
expect(current).toHaveTextContent('Widget')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('shows separators between items', () => {
|
|
47
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
48
|
+
const seps = container.querySelectorAll('.strand-breadcrumb__separator')
|
|
49
|
+
expect(seps.length).toBe(2)
|
|
50
|
+
expect(seps[0]).toHaveAttribute('aria-hidden', 'true')
|
|
51
|
+
expect(seps[0]).toHaveTextContent('/')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('uses custom separator', () => {
|
|
55
|
+
const { container } = render(Breadcrumb, { props: { items: testItems, separator: '>' } })
|
|
56
|
+
const seps = container.querySelectorAll('.strand-breadcrumb__separator')
|
|
57
|
+
expect(seps[0]).toHaveTextContent('>')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not show separator before first item', () => {
|
|
61
|
+
const { container } = render(Breadcrumb, { props: { items: testItems } })
|
|
62
|
+
const firstItem = container.querySelector('.strand-breadcrumb__item')!
|
|
63
|
+
const sep = firstItem.querySelector('.strand-breadcrumb__separator')
|
|
64
|
+
expect(sep).not.toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Visual style variant */
|
|
4
|
+
export let variant: 'primary' | 'secondary' | 'ghost' | 'danger' = 'primary'
|
|
5
|
+
/** Button size */
|
|
6
|
+
export let size: 'sm' | 'md' | 'lg' = 'md'
|
|
7
|
+
/** Show loading spinner and disable interaction */
|
|
8
|
+
export let loading: boolean = false
|
|
9
|
+
/** Square button for icon-only use */
|
|
10
|
+
export let iconOnly: boolean = false
|
|
11
|
+
/** HTML button type */
|
|
12
|
+
export let type: 'button' | 'submit' | 'reset' = 'button'
|
|
13
|
+
/** Disabled state */
|
|
14
|
+
export let disabled: boolean = false
|
|
15
|
+
/** Stretch to full container width */
|
|
16
|
+
export let fullWidth: boolean = false
|
|
17
|
+
/** Click handler */
|
|
18
|
+
export let onclick: ((event: MouseEvent) => void) | undefined = undefined
|
|
19
|
+
|
|
20
|
+
$: isDisabled = disabled || loading
|
|
21
|
+
|
|
22
|
+
$: classes = [
|
|
23
|
+
'strand-btn',
|
|
24
|
+
`strand-btn--${variant}`,
|
|
25
|
+
`strand-btn--${size}`,
|
|
26
|
+
iconOnly && 'strand-btn--icon-only',
|
|
27
|
+
fullWidth && 'strand-btn--full-width',
|
|
28
|
+
loading && 'strand-btn--loading',
|
|
29
|
+
].filter(Boolean).join(' ')
|
|
30
|
+
|
|
31
|
+
function handleClick(event: MouseEvent) {
|
|
32
|
+
if (!isDisabled && onclick) {
|
|
33
|
+
onclick(event)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<button
|
|
39
|
+
{type}
|
|
40
|
+
class={classes}
|
|
41
|
+
disabled={isDisabled}
|
|
42
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
43
|
+
aria-busy={loading ? 'true' : undefined}
|
|
44
|
+
on:click={handleClick}
|
|
45
|
+
>
|
|
46
|
+
{#if loading}
|
|
47
|
+
<span class="strand-btn__spinner" aria-hidden="true"></span>
|
|
48
|
+
{/if}
|
|
49
|
+
<span
|
|
50
|
+
class="strand-btn__content"
|
|
51
|
+
style={loading ? 'visibility: hidden' : undefined}
|
|
52
|
+
>
|
|
53
|
+
<slot />
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
4
|
+
import { render, fireEvent } from '@testing-library/svelte'
|
|
5
|
+
import Button from './Button.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Button', () => {
|
|
8
|
+
it('renders with default props', () => {
|
|
9
|
+
const { getByRole } = render(Button)
|
|
10
|
+
const btn = getByRole('button')
|
|
11
|
+
expect(btn).toBeInTheDocument()
|
|
12
|
+
expect(btn).toHaveClass('strand-btn', 'strand-btn--primary', 'strand-btn--md')
|
|
13
|
+
expect(btn).toHaveAttribute('type', 'button')
|
|
14
|
+
expect(btn).not.toBeDisabled()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('applies variant classes', () => {
|
|
18
|
+
const variants = ['primary', 'secondary', 'ghost', 'danger'] as const
|
|
19
|
+
for (const variant of variants) {
|
|
20
|
+
const { container, unmount } = render(Button, { props: { variant } })
|
|
21
|
+
expect(container.querySelector('.strand-btn')).toHaveClass(`strand-btn--${variant}`)
|
|
22
|
+
unmount()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('applies size classes', () => {
|
|
27
|
+
const sizes = ['sm', 'md', 'lg'] as const
|
|
28
|
+
for (const size of sizes) {
|
|
29
|
+
const { container, unmount } = render(Button, { props: { size } })
|
|
30
|
+
expect(container.querySelector('.strand-btn')).toHaveClass(`strand-btn--${size}`)
|
|
31
|
+
unmount()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('fires click event when clicked', async () => {
|
|
36
|
+
const onClick = vi.fn()
|
|
37
|
+
const { getByRole } = render(Button, { props: { onclick: onClick } })
|
|
38
|
+
await fireEvent.click(getByRole('button'))
|
|
39
|
+
expect(onClick).toHaveBeenCalled()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not fire click when disabled', async () => {
|
|
43
|
+
const onClick = vi.fn()
|
|
44
|
+
const { getByRole } = render(Button, { props: { disabled: true, onclick: onClick } })
|
|
45
|
+
await fireEvent.click(getByRole('button'))
|
|
46
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('sets disabled and aria-disabled when disabled', () => {
|
|
50
|
+
const { getByRole } = render(Button, { props: { disabled: true } })
|
|
51
|
+
const btn = getByRole('button')
|
|
52
|
+
expect(btn).toBeDisabled()
|
|
53
|
+
expect(btn).toHaveAttribute('aria-disabled', 'true')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows loading state with spinner and aria-busy', () => {
|
|
57
|
+
const { getByRole, container } = render(Button, { props: { loading: true } })
|
|
58
|
+
const btn = getByRole('button')
|
|
59
|
+
expect(btn).toHaveClass('strand-btn--loading')
|
|
60
|
+
expect(btn).toHaveAttribute('aria-busy', 'true')
|
|
61
|
+
expect(btn).toBeDisabled()
|
|
62
|
+
expect(btn).toHaveAttribute('aria-disabled', 'true')
|
|
63
|
+
const spinner = container.querySelector('.strand-btn__spinner')
|
|
64
|
+
expect(spinner).toBeInTheDocument()
|
|
65
|
+
expect(spinner).toHaveAttribute('aria-hidden', 'true')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('hides content visibility when loading', () => {
|
|
69
|
+
const { container } = render(Button, { props: { loading: true } })
|
|
70
|
+
const content = container.querySelector('.strand-btn__content')
|
|
71
|
+
expect(content).toHaveStyle({ visibility: 'hidden' })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not fire click when loading', async () => {
|
|
75
|
+
const onClick = vi.fn()
|
|
76
|
+
const { getByRole } = render(Button, { props: { loading: true, onclick: onClick } })
|
|
77
|
+
await fireEvent.click(getByRole('button'))
|
|
78
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('applies iconOnly class', () => {
|
|
82
|
+
const { getByRole } = render(Button, { props: { iconOnly: true } })
|
|
83
|
+
expect(getByRole('button')).toHaveClass('strand-btn--icon-only')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('applies fullWidth class', () => {
|
|
87
|
+
const { getByRole } = render(Button, { props: { fullWidth: true } })
|
|
88
|
+
expect(getByRole('button')).toHaveClass('strand-btn--full-width')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('passes type attribute', () => {
|
|
92
|
+
const { getByRole } = render(Button, { props: { type: 'submit' } })
|
|
93
|
+
expect(getByRole('button')).toHaveAttribute('type', 'submit')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not set aria-disabled when not disabled', () => {
|
|
97
|
+
const { getByRole } = render(Button)
|
|
98
|
+
expect(getByRole('button')).not.toHaveAttribute('aria-disabled')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does not set aria-busy when not loading', () => {
|
|
102
|
+
const { getByRole } = render(Button)
|
|
103
|
+
expect(getByRole('button')).not.toHaveAttribute('aria-busy')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not show spinner when not loading', () => {
|
|
107
|
+
const { container } = render(Button)
|
|
108
|
+
expect(container.querySelector('.strand-btn__spinner')).not.toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Visual style variant */
|
|
4
|
+
export let variant: 'elevated' | 'outlined' | 'interactive' = 'elevated'
|
|
5
|
+
/** Inner padding */
|
|
6
|
+
export let padding: 'none' | 'sm' | 'md' | 'lg' = 'md'
|
|
7
|
+
|
|
8
|
+
$: classes = [
|
|
9
|
+
'strand-card',
|
|
10
|
+
`strand-card--${variant}`,
|
|
11
|
+
`strand-card--pad-${padding}`,
|
|
12
|
+
].filter(Boolean).join(' ')
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class={classes} {...$$restProps}>
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|