@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
|
@@ -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,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,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,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,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,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,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
|
+
})
|