@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.
Files changed (105) hide show
  1. package/README.md +56 -0
  2. package/dist/css/strand-ui.css +2583 -0
  3. package/dist/index.js +4154 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +53 -0
  6. package/src/components/Alert/Alert.svelte +32 -0
  7. package/src/components/Alert/Alert.test.ts +64 -0
  8. package/src/components/Alert/index.ts +2 -0
  9. package/src/components/Avatar/Avatar.svelte +40 -0
  10. package/src/components/Avatar/Avatar.test.ts +55 -0
  11. package/src/components/Avatar/index.ts +2 -0
  12. package/src/components/Badge/Badge.svelte +41 -0
  13. package/src/components/Badge/Badge.test.ts +55 -0
  14. package/src/components/Badge/index.ts +2 -0
  15. package/src/components/Breadcrumb/Breadcrumb.svelte +29 -0
  16. package/src/components/Breadcrumb/Breadcrumb.test.ts +66 -0
  17. package/src/components/Breadcrumb/index.ts +2 -0
  18. package/src/components/Button/Button.svelte +55 -0
  19. package/src/components/Button/Button.test.ts +110 -0
  20. package/src/components/Button/index.ts +2 -0
  21. package/src/components/Card/Card.svelte +17 -0
  22. package/src/components/Card/Card.test.ts +32 -0
  23. package/src/components/Card/index.ts +2 -0
  24. package/src/components/Checkbox/Checkbox.svelte +62 -0
  25. package/src/components/Checkbox/Checkbox.test.ts +67 -0
  26. package/src/components/Checkbox/index.ts +2 -0
  27. package/src/components/CodeBlock/CodeBlock.svelte +14 -0
  28. package/src/components/CodeBlock/CodeBlock.test.ts +36 -0
  29. package/src/components/CodeBlock/index.ts +2 -0
  30. package/src/components/Container/Container.svelte +14 -0
  31. package/src/components/Container/Container.test.ts +23 -0
  32. package/src/components/Container/index.ts +2 -0
  33. package/src/components/DataReadout/DataReadout.svelte +19 -0
  34. package/src/components/DataReadout/DataReadout.test.ts +35 -0
  35. package/src/components/DataReadout/index.ts +2 -0
  36. package/src/components/Dialog/Dialog.svelte +131 -0
  37. package/src/components/Dialog/Dialog.test.ts +77 -0
  38. package/src/components/Dialog/index.ts +2 -0
  39. package/src/components/Divider/Divider.svelte +36 -0
  40. package/src/components/Divider/Divider.test.ts +34 -0
  41. package/src/components/Divider/index.ts +2 -0
  42. package/src/components/FormField/FormField.svelte +39 -0
  43. package/src/components/FormField/FormField.test.ts +58 -0
  44. package/src/components/FormField/index.ts +2 -0
  45. package/src/components/Grid/Grid.svelte +13 -0
  46. package/src/components/Grid/Grid.test.ts +32 -0
  47. package/src/components/Grid/index.ts +2 -0
  48. package/src/components/Input/Input.svelte +41 -0
  49. package/src/components/Input/Input.test.ts +64 -0
  50. package/src/components/Input/index.ts +2 -0
  51. package/src/components/Link/Link.svelte +17 -0
  52. package/src/components/Link/Link.test.ts +28 -0
  53. package/src/components/Link/index.ts +2 -0
  54. package/src/components/Nav/Nav.svelte +69 -0
  55. package/src/components/Nav/Nav.test.ts +75 -0
  56. package/src/components/Nav/index.ts +2 -0
  57. package/src/components/Progress/Progress.svelte +78 -0
  58. package/src/components/Progress/Progress.test.ts +58 -0
  59. package/src/components/Progress/index.ts +2 -0
  60. package/src/components/Radio/Radio.svelte +46 -0
  61. package/src/components/Radio/Radio.test.ts +52 -0
  62. package/src/components/Radio/index.ts +2 -0
  63. package/src/components/Section/Section.svelte +17 -0
  64. package/src/components/Section/Section.test.ts +29 -0
  65. package/src/components/Section/index.ts +2 -0
  66. package/src/components/Select/Select.svelte +45 -0
  67. package/src/components/Select/Select.test.ts +59 -0
  68. package/src/components/Select/index.ts +2 -0
  69. package/src/components/Skeleton/Skeleton.svelte +25 -0
  70. package/src/components/Skeleton/Skeleton.test.ts +44 -0
  71. package/src/components/Skeleton/index.ts +2 -0
  72. package/src/components/Slider/Slider.svelte +37 -0
  73. package/src/components/Slider/Slider.test.ts +45 -0
  74. package/src/components/Slider/index.ts +2 -0
  75. package/src/components/Spinner/Spinner.svelte +15 -0
  76. package/src/components/Spinner/Spinner.test.ts +38 -0
  77. package/src/components/Spinner/index.ts +2 -0
  78. package/src/components/Stack/Stack.svelte +27 -0
  79. package/src/components/Stack/Stack.test.ts +46 -0
  80. package/src/components/Stack/index.ts +2 -0
  81. package/src/components/Switch/Switch.svelte +48 -0
  82. package/src/components/Switch/Switch.test.ts +61 -0
  83. package/src/components/Switch/index.ts +2 -0
  84. package/src/components/Table/Table.svelte +67 -0
  85. package/src/components/Table/Table.test.ts +88 -0
  86. package/src/components/Table/index.ts +2 -0
  87. package/src/components/Tabs/Tabs.svelte +89 -0
  88. package/src/components/Tabs/Tabs.test.ts +66 -0
  89. package/src/components/Tabs/index.ts +2 -0
  90. package/src/components/Tag/Tag.svelte +33 -0
  91. package/src/components/Tag/Tag.test.ts +63 -0
  92. package/src/components/Tag/index.ts +2 -0
  93. package/src/components/Textarea/Textarea.svelte +53 -0
  94. package/src/components/Textarea/Textarea.test.ts +53 -0
  95. package/src/components/Textarea/index.ts +2 -0
  96. package/src/components/Toast/Toast.svelte +29 -0
  97. package/src/components/Toast/Toast.test.ts +60 -0
  98. package/src/components/Toast/ToastProvider.svelte +45 -0
  99. package/src/components/Toast/index.ts +5 -0
  100. package/src/components/Toast/useToast.ts +78 -0
  101. package/src/components/Tooltip/Tooltip.svelte +56 -0
  102. package/src/components/Tooltip/Tooltip.test.ts +50 -0
  103. package/src/components/Tooltip/index.ts +2 -0
  104. package/src/index.ts +46 -0
  105. 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
+ &#215;
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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Alert } from './Alert.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Avatar } from './Avatar.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Badge } from './Badge.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Breadcrumb } from './Breadcrumb.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Button } from './Button.svelte'
@@ -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>