@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
@@ -0,0 +1,58 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import FormField from './FormField.svelte'
6
+
7
+ describe('FormField', () => {
8
+ it('renders with label', () => {
9
+ const { container } = render(FormField, { props: { label: 'Email', htmlFor: 'email' } })
10
+ expect(container.querySelector('.strand-form-field')).toBeInTheDocument()
11
+ const labelEl = container.querySelector('.strand-form-field__label')
12
+ expect(labelEl).toBeInTheDocument()
13
+ expect(labelEl).toHaveTextContent('Email')
14
+ expect(labelEl).toHaveAttribute('for', 'email')
15
+ })
16
+
17
+ it('shows required indicator', () => {
18
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name', required: true } })
19
+ const req = container.querySelector('.strand-form-field__required')
20
+ expect(req).toBeInTheDocument()
21
+ expect(req).toHaveTextContent('*')
22
+ expect(req).toHaveAttribute('aria-hidden', 'true')
23
+ })
24
+
25
+ it('does not show required by default', () => {
26
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name' } })
27
+ expect(container.querySelector('.strand-form-field__required')).not.toBeInTheDocument()
28
+ })
29
+
30
+ it('shows hint text', () => {
31
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name', hint: 'Enter your name' } })
32
+ const hint = container.querySelector('.strand-form-field__hint')
33
+ expect(hint).toBeInTheDocument()
34
+ expect(hint).toHaveTextContent('Enter your name')
35
+ expect(hint).toHaveAttribute('id', 'name-hint')
36
+ })
37
+
38
+ it('shows error text and applies error class', () => {
39
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name', error: 'Required' } })
40
+ expect(container.querySelector('.strand-form-field')).toHaveClass('strand-form-field--error')
41
+ const errorEl = container.querySelector('.strand-form-field__error')
42
+ expect(errorEl).toBeInTheDocument()
43
+ expect(errorEl).toHaveTextContent('Required')
44
+ expect(errorEl).toHaveAttribute('role', 'alert')
45
+ expect(errorEl).toHaveAttribute('id', 'name-error')
46
+ })
47
+
48
+ it('error takes precedence over hint', () => {
49
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name', error: 'Required', hint: 'Enter name' } })
50
+ expect(container.querySelector('.strand-form-field__error')).toBeInTheDocument()
51
+ expect(container.querySelector('.strand-form-field__hint')).not.toBeInTheDocument()
52
+ })
53
+
54
+ it('has control wrapper', () => {
55
+ const { container } = render(FormField, { props: { label: 'Name', htmlFor: 'name' } })
56
+ expect(container.querySelector('.strand-form-field__control')).toBeInTheDocument()
57
+ })
58
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as FormField } from './FormField.svelte'
@@ -0,0 +1,13 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ /** Number of equal-width columns */
4
+ export let columns: number = 1
5
+ /** Gap between items, maps to --strand-space-{n} */
6
+ export let gap: number = 4
7
+
8
+ $: inlineStyle = `grid-template-columns: repeat(${columns}, 1fr); gap: var(--strand-space-${gap});`
9
+ </script>
10
+
11
+ <div class="strand-grid" style={inlineStyle} {...$$restProps}>
12
+ <slot />
13
+ </div>
@@ -0,0 +1,32 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import Grid from './Grid.svelte'
6
+
7
+ describe('Grid', () => {
8
+ it('renders with default class', () => {
9
+ const { container } = render(Grid)
10
+ const el = container.querySelector('.strand-grid')
11
+ expect(el).toBeInTheDocument()
12
+ })
13
+
14
+ it('sets grid-template-columns from columns prop', () => {
15
+ const { container } = render(Grid, { props: { columns: 3 } })
16
+ const el = container.querySelector('.strand-grid') as HTMLElement
17
+ expect(el.style.gridTemplateColumns).toBe('repeat(3, 1fr)')
18
+ })
19
+
20
+ it('sets gap from gap prop', () => {
21
+ const { container } = render(Grid, { props: { gap: 6 } })
22
+ const el = container.querySelector('.strand-grid') as HTMLElement
23
+ expect(el.style.gap).toBe('var(--strand-space-6)')
24
+ })
25
+
26
+ it('uses default columns=1 and gap=4', () => {
27
+ const { container } = render(Grid)
28
+ const el = container.querySelector('.strand-grid') as HTMLElement
29
+ expect(el.style.gridTemplateColumns).toBe('repeat(1, 1fr)')
30
+ expect(el.style.gap).toBe('var(--strand-space-4)')
31
+ })
32
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Grid } from './Grid.svelte'
@@ -0,0 +1,41 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ /** Input type */
4
+ export let type: 'text' | 'email' | 'password' | 'search' | 'number' = 'text'
5
+ /** Show error styling */
6
+ export let error: boolean = false
7
+ /** Disabled state */
8
+ export let disabled: boolean = false
9
+ /** Whether there is a leading addon */
10
+ export let hasLeading: boolean = false
11
+ /** Whether there is a trailing addon */
12
+ export let hasTrailing: boolean = false
13
+
14
+ $: wrapperClasses = [
15
+ 'strand-input',
16
+ error && 'strand-input--error',
17
+ disabled && 'strand-input--disabled',
18
+ hasLeading && 'strand-input--has-leading',
19
+ hasTrailing && 'strand-input--has-trailing',
20
+ ].filter(Boolean).join(' ')
21
+ </script>
22
+
23
+ <div class={wrapperClasses}>
24
+ {#if hasLeading}
25
+ <span class="strand-input__leading" aria-hidden="true">
26
+ <slot name="leading" />
27
+ </span>
28
+ {/if}
29
+ <input
30
+ {type}
31
+ class="strand-input__field"
32
+ {disabled}
33
+ aria-invalid={error ? 'true' : undefined}
34
+ {...$$restProps}
35
+ />
36
+ {#if hasTrailing}
37
+ <span class="strand-input__trailing" aria-hidden="true">
38
+ <slot name="trailing" />
39
+ </span>
40
+ {/if}
41
+ </div>
@@ -0,0 +1,64 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import Input from './Input.svelte'
6
+
7
+ describe('Input', () => {
8
+ it('renders with default classes', () => {
9
+ const { container } = render(Input)
10
+ const wrapper = container.querySelector('.strand-input')
11
+ expect(wrapper).toBeInTheDocument()
12
+ const input = container.querySelector('.strand-input__field')
13
+ expect(input).toBeInTheDocument()
14
+ expect(input).toHaveAttribute('type', 'text')
15
+ })
16
+
17
+ it('applies error class and aria-invalid', () => {
18
+ const { container } = render(Input, { props: { error: true } })
19
+ expect(container.querySelector('.strand-input')).toHaveClass('strand-input--error')
20
+ expect(container.querySelector('.strand-input__field')).toHaveAttribute('aria-invalid', 'true')
21
+ })
22
+
23
+ it('applies disabled class and attribute', () => {
24
+ const { container } = render(Input, { props: { disabled: true } })
25
+ expect(container.querySelector('.strand-input')).toHaveClass('strand-input--disabled')
26
+ expect(container.querySelector('.strand-input__field')).toBeDisabled()
27
+ })
28
+
29
+ it('applies type attribute', () => {
30
+ const types = ['text', 'email', 'password', 'search', 'number'] as const
31
+ for (const type of types) {
32
+ const { container, unmount } = render(Input, { props: { type } })
33
+ expect(container.querySelector('.strand-input__field')).toHaveAttribute('type', type)
34
+ unmount()
35
+ }
36
+ })
37
+
38
+ it('shows leading addon area when hasLeading', () => {
39
+ const { container } = render(Input, { props: { hasLeading: true } })
40
+ expect(container.querySelector('.strand-input--has-leading')).toBeInTheDocument()
41
+ const leading = container.querySelector('.strand-input__leading')
42
+ expect(leading).toBeInTheDocument()
43
+ expect(leading).toHaveAttribute('aria-hidden', 'true')
44
+ })
45
+
46
+ it('shows trailing addon area when hasTrailing', () => {
47
+ const { container } = render(Input, { props: { hasTrailing: true } })
48
+ expect(container.querySelector('.strand-input--has-trailing')).toBeInTheDocument()
49
+ const trailing = container.querySelector('.strand-input__trailing')
50
+ expect(trailing).toBeInTheDocument()
51
+ expect(trailing).toHaveAttribute('aria-hidden', 'true')
52
+ })
53
+
54
+ it('does not show addons by default', () => {
55
+ const { container } = render(Input)
56
+ expect(container.querySelector('.strand-input__leading')).not.toBeInTheDocument()
57
+ expect(container.querySelector('.strand-input__trailing')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('does not set aria-invalid when no error', () => {
61
+ const { container } = render(Input)
62
+ expect(container.querySelector('.strand-input__field')).not.toHaveAttribute('aria-invalid')
63
+ })
64
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Input } from './Input.svelte'
@@ -0,0 +1,17 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ /** URL destination */
4
+ export let href: string
5
+ /** Opens in new tab with rel="noopener noreferrer" */
6
+ export let external: boolean = false
7
+ </script>
8
+
9
+ <a
10
+ {href}
11
+ class="strand-link"
12
+ target={external ? '_blank' : undefined}
13
+ rel={external ? 'noopener noreferrer' : undefined}
14
+ {...$$restProps}
15
+ >
16
+ <slot />
17
+ </a>
@@ -0,0 +1,28 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import Link from './Link.svelte'
6
+
7
+ describe('Link', () => {
8
+ it('renders with class and href', () => {
9
+ const { container } = render(Link, { props: { href: '/test' } })
10
+ const el = container.querySelector('.strand-link')
11
+ expect(el).toBeInTheDocument()
12
+ expect(el).toHaveAttribute('href', '/test')
13
+ })
14
+
15
+ it('does not set target or rel by default', () => {
16
+ const { container } = render(Link, { props: { href: '/test' } })
17
+ const el = container.querySelector('.strand-link')
18
+ expect(el).not.toHaveAttribute('target')
19
+ expect(el).not.toHaveAttribute('rel')
20
+ })
21
+
22
+ it('sets target and rel for external links', () => {
23
+ const { container } = render(Link, { props: { href: 'https://example.com', external: true } })
24
+ const el = container.querySelector('.strand-link')
25
+ expect(el).toHaveAttribute('target', '_blank')
26
+ expect(el).toHaveAttribute('rel', 'noopener noreferrer')
27
+ })
28
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Link } from './Link.svelte'
@@ -0,0 +1,69 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ export interface NavItem {
4
+ label: string
5
+ href: string
6
+ active?: boolean
7
+ }
8
+
9
+ /** Navigation items */
10
+ export let items: NavItem[] = []
11
+
12
+ let menuOpen = false
13
+
14
+ function toggleMenu() {
15
+ menuOpen = !menuOpen
16
+ }
17
+ </script>
18
+
19
+ <nav class="strand-nav" aria-label="Main navigation" {...$$restProps}>
20
+ <div class="strand-nav__inner">
21
+ {#if $$slots.logo}
22
+ <div class="strand-nav__logo">
23
+ <slot name="logo" />
24
+ </div>
25
+ {/if}
26
+
27
+ <div class="strand-nav__items">
28
+ {#each items as item (item.href)}
29
+ <a
30
+ href={item.href}
31
+ class={['strand-nav__link', item.active && 'strand-nav__link--active'].filter(Boolean).join(' ')}
32
+ aria-current={item.active ? 'page' : undefined}
33
+ >
34
+ {item.label}
35
+ </a>
36
+ {/each}
37
+ </div>
38
+
39
+ {#if $$slots.actions}
40
+ <div class="strand-nav__actions">
41
+ <slot name="actions" />
42
+ </div>
43
+ {/if}
44
+
45
+ <button
46
+ type="button"
47
+ class="strand-nav__hamburger"
48
+ aria-expanded={menuOpen ? 'true' : 'false'}
49
+ aria-label={menuOpen ? 'Close menu' : 'Menu'}
50
+ on:click={toggleMenu}
51
+ >
52
+ <span class="strand-nav__hamburger-icon" aria-hidden="true"></span>
53
+ </button>
54
+ </div>
55
+
56
+ {#if menuOpen}
57
+ <div class="strand-nav__mobile-menu">
58
+ {#each items as item (item.href)}
59
+ <a
60
+ href={item.href}
61
+ class={['strand-nav__mobile-link', item.active && 'strand-nav__mobile-link--active'].filter(Boolean).join(' ')}
62
+ aria-current={item.active ? 'page' : undefined}
63
+ >
64
+ {item.label}
65
+ </a>
66
+ {/each}
67
+ </div>
68
+ {/if}
69
+ </nav>
@@ -0,0 +1,75 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render, fireEvent } from '@testing-library/svelte'
5
+ import Nav from './Nav.svelte'
6
+
7
+ const testItems = [
8
+ { label: 'Home', href: '/', active: true },
9
+ { label: 'About', href: '/about' },
10
+ { label: 'Contact', href: '/contact' },
11
+ ]
12
+
13
+ describe('Nav', () => {
14
+ it('renders with nav element and aria-label', () => {
15
+ const { container } = render(Nav, { props: { items: testItems } })
16
+ const nav = container.querySelector('nav')
17
+ expect(nav).toBeInTheDocument()
18
+ expect(nav).toHaveClass('strand-nav')
19
+ expect(nav).toHaveAttribute('aria-label', 'Main navigation')
20
+ })
21
+
22
+ it('renders navigation items', () => {
23
+ const { container } = render(Nav, { props: { items: testItems } })
24
+ const links = container.querySelectorAll('.strand-nav__link')
25
+ expect(links.length).toBe(3)
26
+ expect(links[0]).toHaveTextContent('Home')
27
+ expect(links[0]).toHaveAttribute('href', '/')
28
+ })
29
+
30
+ it('marks active item with class and aria-current', () => {
31
+ const { container } = render(Nav, { props: { items: testItems } })
32
+ const links = container.querySelectorAll('.strand-nav__link')
33
+ expect(links[0]).toHaveClass('strand-nav__link--active')
34
+ expect(links[0]).toHaveAttribute('aria-current', 'page')
35
+ expect(links[1]).not.toHaveClass('strand-nav__link--active')
36
+ expect(links[1]).not.toHaveAttribute('aria-current')
37
+ })
38
+
39
+ it('has hamburger button', () => {
40
+ const { container } = render(Nav, { props: { items: testItems } })
41
+ const btn = container.querySelector('.strand-nav__hamburger')
42
+ expect(btn).toBeInTheDocument()
43
+ expect(btn).toHaveAttribute('aria-expanded', 'false')
44
+ expect(btn).toHaveAttribute('aria-label', 'Menu')
45
+ })
46
+
47
+ it('toggles mobile menu on hamburger click', async () => {
48
+ const { container } = render(Nav, { props: { items: testItems } })
49
+ const btn = container.querySelector('.strand-nav__hamburger')!
50
+ expect(container.querySelector('.strand-nav__mobile-menu')).not.toBeInTheDocument()
51
+ await fireEvent.click(btn)
52
+ expect(container.querySelector('.strand-nav__mobile-menu')).toBeInTheDocument()
53
+ expect(btn).toHaveAttribute('aria-expanded', 'true')
54
+ expect(btn).toHaveAttribute('aria-label', 'Close menu')
55
+ })
56
+
57
+ it('renders mobile links when menu is open', async () => {
58
+ const { container } = render(Nav, { props: { items: testItems } })
59
+ await fireEvent.click(container.querySelector('.strand-nav__hamburger')!)
60
+ const mobileLinks = container.querySelectorAll('.strand-nav__mobile-link')
61
+ expect(mobileLinks.length).toBe(3)
62
+ expect(mobileLinks[0]).toHaveClass('strand-nav__mobile-link--active')
63
+ })
64
+
65
+ it('has hamburger icon with aria-hidden', () => {
66
+ const { container } = render(Nav, { props: { items: testItems } })
67
+ const icon = container.querySelector('.strand-nav__hamburger-icon')
68
+ expect(icon).toHaveAttribute('aria-hidden', 'true')
69
+ })
70
+
71
+ it('has inner wrapper', () => {
72
+ const { container } = render(Nav, { props: { items: testItems } })
73
+ expect(container.querySelector('.strand-nav__inner')).toBeInTheDocument()
74
+ })
75
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Nav } from './Nav.svelte'
@@ -0,0 +1,78 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ /** Visual variant */
4
+ export let variant: 'bar' | 'ring' = 'bar'
5
+ /** Completion percentage (0-100). Omit for indeterminate. */
6
+ export let value: number | undefined = undefined
7
+ /** Size of the progress indicator */
8
+ export let size: 'sm' | 'md' | 'lg' = 'md'
9
+
10
+ const RING_SIZES: Record<string, number> = { sm: 24, md: 40, lg: 56 }
11
+ const RING_STROKE = 3
12
+
13
+ $: isDeterminate = value != null
14
+ $: classes = [
15
+ 'strand-progress',
16
+ `strand-progress--${variant}`,
17
+ `strand-progress--${size}`,
18
+ !isDeterminate && 'strand-progress--indeterminate',
19
+ ].filter(Boolean).join(' ')
20
+
21
+ $: dim = RING_SIZES[size] ?? RING_SIZES.md
22
+ $: radius = (dim - RING_STROKE) / 2
23
+ $: circumference = 2 * Math.PI * radius
24
+ $: offset = isDeterminate ? circumference - (circumference * (value as number)) / 100 : 0
25
+ </script>
26
+
27
+ {#if variant === 'ring'}
28
+ <div
29
+ class={classes}
30
+ role="progressbar"
31
+ aria-valuemin={0}
32
+ aria-valuemax={100}
33
+ aria-valuenow={isDeterminate ? value : undefined}
34
+ {...$$restProps}
35
+ >
36
+ <svg
37
+ width={dim}
38
+ height={dim}
39
+ viewBox={`0 0 ${dim} ${dim}`}
40
+ class="strand-progress__ring"
41
+ >
42
+ <circle
43
+ cx={dim / 2}
44
+ cy={dim / 2}
45
+ r={radius}
46
+ fill="none"
47
+ stroke-width={RING_STROKE}
48
+ class="strand-progress__track"
49
+ />
50
+ <circle
51
+ cx={dim / 2}
52
+ cy={dim / 2}
53
+ r={radius}
54
+ fill="none"
55
+ stroke-width={RING_STROKE}
56
+ stroke-dasharray={circumference}
57
+ stroke-dashoffset={isDeterminate ? offset : undefined}
58
+ stroke-linecap="round"
59
+ class="strand-progress__fill"
60
+ transform={`rotate(-90 ${dim / 2} ${dim / 2})`}
61
+ />
62
+ </svg>
63
+ </div>
64
+ {:else}
65
+ <div
66
+ class={classes}
67
+ role="progressbar"
68
+ aria-valuemin={0}
69
+ aria-valuemax={100}
70
+ aria-valuenow={isDeterminate ? value : undefined}
71
+ {...$$restProps}
72
+ >
73
+ <div
74
+ class="strand-progress__fill"
75
+ style={isDeterminate ? `width: ${value}%` : undefined}
76
+ ></div>
77
+ </div>
78
+ {/if}
@@ -0,0 +1,58 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import Progress from './Progress.svelte'
6
+
7
+ describe('Progress', () => {
8
+ it('renders bar with default classes', () => {
9
+ const { container } = render(Progress)
10
+ const el = container.querySelector('.strand-progress')
11
+ expect(el).toBeInTheDocument()
12
+ expect(el).toHaveClass('strand-progress--bar', 'strand-progress--md')
13
+ expect(el).toHaveAttribute('role', 'progressbar')
14
+ })
15
+
16
+ it('shows indeterminate when no value', () => {
17
+ const { container } = render(Progress)
18
+ expect(container.querySelector('.strand-progress')).toHaveClass('strand-progress--indeterminate')
19
+ })
20
+
21
+ it('shows determinate with value', () => {
22
+ const { container } = render(Progress, { props: { value: 50 } })
23
+ const el = container.querySelector('.strand-progress')
24
+ expect(el).not.toHaveClass('strand-progress--indeterminate')
25
+ expect(el).toHaveAttribute('aria-valuenow', '50')
26
+ })
27
+
28
+ it('sets fill width for determinate bar', () => {
29
+ const { container } = render(Progress, { props: { value: 75 } })
30
+ const fill = container.querySelector('.strand-progress__fill') as HTMLElement
31
+ expect(fill.style.width).toBe('75%')
32
+ })
33
+
34
+ it('applies size classes', () => {
35
+ const sizes = ['sm', 'md', 'lg'] as const
36
+ for (const size of sizes) {
37
+ const { container, unmount } = render(Progress, { props: { size } })
38
+ expect(container.querySelector('.strand-progress')).toHaveClass(`strand-progress--${size}`)
39
+ unmount()
40
+ }
41
+ })
42
+
43
+ it('renders ring variant with SVG', () => {
44
+ const { container } = render(Progress, { props: { variant: 'ring', value: 50 } })
45
+ const el = container.querySelector('.strand-progress')
46
+ expect(el).toHaveClass('strand-progress--ring')
47
+ expect(container.querySelector('svg')).toBeInTheDocument()
48
+ expect(container.querySelector('.strand-progress__track')).toBeInTheDocument()
49
+ expect(container.querySelector('.strand-progress__fill')).toBeInTheDocument()
50
+ })
51
+
52
+ it('has aria-valuemin and aria-valuemax', () => {
53
+ const { container } = render(Progress, { props: { value: 25 } })
54
+ const el = container.querySelector('.strand-progress')
55
+ expect(el).toHaveAttribute('aria-valuemin', '0')
56
+ expect(el).toHaveAttribute('aria-valuemax', '100')
57
+ })
58
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Progress } from './Progress.svelte'
@@ -0,0 +1,46 @@
1
+ <!--! Strand Svelte | MIT License | dillingerstaffing.com -->
2
+ <script lang="ts">
3
+ /** Controlled checked state */
4
+ export let checked: boolean = false
5
+ /** Disabled state */
6
+ export let disabled: boolean = false
7
+ /** Label text */
8
+ export let label: string | undefined = undefined
9
+ /** Radio group name */
10
+ export let name: string | undefined = undefined
11
+ /** Radio value */
12
+ export let value: string | undefined = undefined
13
+ /** Change handler */
14
+ export let onchange: ((e: Event) => void) | undefined = undefined
15
+
16
+ $: classes = [
17
+ 'strand-radio',
18
+ checked && 'strand-radio--checked',
19
+ disabled && 'strand-radio--disabled',
20
+ ].filter(Boolean).join(' ')
21
+
22
+ function handleChange(e: Event) {
23
+ if (!disabled) {
24
+ onchange?.(e)
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <label class={classes}>
30
+ <input
31
+ type="radio"
32
+ class="strand-radio__native"
33
+ {checked}
34
+ {disabled}
35
+ {name}
36
+ {value}
37
+ on:change={handleChange}
38
+ {...$$restProps}
39
+ />
40
+ <span class="strand-radio__control" aria-hidden="true">
41
+ <span class="strand-radio__dot"></span>
42
+ </span>
43
+ {#if label}
44
+ <span class="strand-radio__label">{label}</span>
45
+ {/if}
46
+ </label>
@@ -0,0 +1,52 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+
3
+ import { describe, it, expect } from 'vitest'
4
+ import { render } from '@testing-library/svelte'
5
+ import Radio from './Radio.svelte'
6
+
7
+ describe('Radio', () => {
8
+ it('renders with default classes', () => {
9
+ const { container } = render(Radio)
10
+ expect(container.querySelector('.strand-radio')).toBeInTheDocument()
11
+ const input = container.querySelector('.strand-radio__native')
12
+ expect(input).toBeInTheDocument()
13
+ expect(input).toHaveAttribute('type', 'radio')
14
+ })
15
+
16
+ it('applies checked class', () => {
17
+ const { container } = render(Radio, { props: { checked: true } })
18
+ expect(container.querySelector('.strand-radio')).toHaveClass('strand-radio--checked')
19
+ })
20
+
21
+ it('applies disabled class and attribute', () => {
22
+ const { container } = render(Radio, { props: { disabled: true } })
23
+ expect(container.querySelector('.strand-radio')).toHaveClass('strand-radio--disabled')
24
+ expect(container.querySelector('.strand-radio__native')).toBeDisabled()
25
+ })
26
+
27
+ it('renders label text', () => {
28
+ const { container } = render(Radio, { props: { label: 'Option A' } })
29
+ const labelEl = container.querySelector('.strand-radio__label')
30
+ expect(labelEl).toBeInTheDocument()
31
+ expect(labelEl).toHaveTextContent('Option A')
32
+ })
33
+
34
+ it('does not render label when not provided', () => {
35
+ const { container } = render(Radio)
36
+ expect(container.querySelector('.strand-radio__label')).not.toBeInTheDocument()
37
+ })
38
+
39
+ it('passes name and value', () => {
40
+ const { container } = render(Radio, { props: { name: 'group1', value: 'a' } })
41
+ const input = container.querySelector('.strand-radio__native')
42
+ expect(input).toHaveAttribute('name', 'group1')
43
+ expect(input).toHaveAttribute('value', 'a')
44
+ })
45
+
46
+ it('has control with aria-hidden and dot', () => {
47
+ const { container } = render(Radio)
48
+ const control = container.querySelector('.strand-radio__control')
49
+ expect(control).toHaveAttribute('aria-hidden', 'true')
50
+ expect(container.querySelector('.strand-radio__dot')).toBeInTheDocument()
51
+ })
52
+ })
@@ -0,0 +1,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Radio } from './Radio.svelte'