@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,17 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Padding variant */
|
|
4
|
+
export let variant: 'standard' | 'hero' = 'standard'
|
|
5
|
+
/** Surface background */
|
|
6
|
+
export let background: 'primary' | 'elevated' | 'recessed' = 'primary'
|
|
7
|
+
|
|
8
|
+
$: classes = [
|
|
9
|
+
'strand-section',
|
|
10
|
+
`strand-section--${variant}`,
|
|
11
|
+
`strand-section--bg-${background}`,
|
|
12
|
+
].filter(Boolean).join(' ')
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<section class={classes} {...$$restProps}>
|
|
16
|
+
<slot />
|
|
17
|
+
</section>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Section from './Section.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Section', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Section)
|
|
10
|
+
const el = container.querySelector('.strand-section')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el!.tagName).toBe('SECTION')
|
|
13
|
+
expect(el).toHaveClass('strand-section--standard', 'strand-section--bg-primary')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies variant classes', () => {
|
|
17
|
+
const { container } = render(Section, { props: { variant: 'hero' } })
|
|
18
|
+
expect(container.querySelector('.strand-section')).toHaveClass('strand-section--hero')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('applies background classes', () => {
|
|
22
|
+
const backgrounds = ['primary', 'elevated', 'recessed'] as const
|
|
23
|
+
for (const background of backgrounds) {
|
|
24
|
+
const { container, unmount } = render(Section, { props: { background } })
|
|
25
|
+
expect(container.querySelector('.strand-section')).toHaveClass(`strand-section--bg-${background}`)
|
|
26
|
+
unmount()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
export interface SelectOption {
|
|
4
|
+
value: string
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Array of options to display */
|
|
9
|
+
export let options: SelectOption[] = []
|
|
10
|
+
/** Disabled state */
|
|
11
|
+
export let disabled: boolean = false
|
|
12
|
+
/** Currently selected value */
|
|
13
|
+
export let value: string | undefined = undefined
|
|
14
|
+
/** Show error styling */
|
|
15
|
+
export let error: boolean = false
|
|
16
|
+
/** Placeholder text shown as first disabled option */
|
|
17
|
+
export let placeholder: string | undefined = undefined
|
|
18
|
+
/** Change handler */
|
|
19
|
+
export let onchange: ((e: Event) => void) | undefined = undefined
|
|
20
|
+
|
|
21
|
+
$: wrapperClasses = [
|
|
22
|
+
'strand-select',
|
|
23
|
+
error && 'strand-select--error',
|
|
24
|
+
disabled && 'strand-select--disabled',
|
|
25
|
+
].filter(Boolean).join(' ')
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div class={wrapperClasses}>
|
|
29
|
+
<select
|
|
30
|
+
class="strand-select__field"
|
|
31
|
+
{value}
|
|
32
|
+
{disabled}
|
|
33
|
+
aria-invalid={error ? 'true' : undefined}
|
|
34
|
+
on:change={onchange}
|
|
35
|
+
{...$$restProps}
|
|
36
|
+
>
|
|
37
|
+
{#if placeholder}
|
|
38
|
+
<option value="" disabled>{placeholder}</option>
|
|
39
|
+
{/if}
|
|
40
|
+
{#each options as opt (opt.value)}
|
|
41
|
+
<option value={opt.value}>{opt.label}</option>
|
|
42
|
+
{/each}
|
|
43
|
+
</select>
|
|
44
|
+
<span class="strand-select__arrow" aria-hidden="true"></span>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Select from './Select.svelte'
|
|
6
|
+
|
|
7
|
+
const testOptions = [
|
|
8
|
+
{ value: 'a', label: 'Option A' },
|
|
9
|
+
{ value: 'b', label: 'Option B' },
|
|
10
|
+
{ value: 'c', label: 'Option C' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('Select', () => {
|
|
14
|
+
it('renders with default classes', () => {
|
|
15
|
+
const { container } = render(Select, { props: { options: testOptions } })
|
|
16
|
+
expect(container.querySelector('.strand-select')).toBeInTheDocument()
|
|
17
|
+
expect(container.querySelector('.strand-select__field')).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders options', () => {
|
|
21
|
+
const { container } = render(Select, { props: { options: testOptions } })
|
|
22
|
+
const optionEls = container.querySelectorAll('option')
|
|
23
|
+
expect(optionEls.length).toBe(3)
|
|
24
|
+
expect(optionEls[0]).toHaveTextContent('Option A')
|
|
25
|
+
expect(optionEls[1]).toHaveTextContent('Option B')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders placeholder as disabled option', () => {
|
|
29
|
+
const { container } = render(Select, { props: { options: testOptions, placeholder: 'Choose...' } })
|
|
30
|
+
const optionEls = container.querySelectorAll('option')
|
|
31
|
+
expect(optionEls.length).toBe(4)
|
|
32
|
+
expect(optionEls[0]).toHaveTextContent('Choose...')
|
|
33
|
+
expect(optionEls[0]).toBeDisabled()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('applies error class and aria-invalid', () => {
|
|
37
|
+
const { container } = render(Select, { props: { options: testOptions, error: true } })
|
|
38
|
+
expect(container.querySelector('.strand-select')).toHaveClass('strand-select--error')
|
|
39
|
+
expect(container.querySelector('.strand-select__field')).toHaveAttribute('aria-invalid', 'true')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('applies disabled class and attribute', () => {
|
|
43
|
+
const { container } = render(Select, { props: { options: testOptions, disabled: true } })
|
|
44
|
+
expect(container.querySelector('.strand-select')).toHaveClass('strand-select--disabled')
|
|
45
|
+
expect(container.querySelector('.strand-select__field')).toBeDisabled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('has arrow indicator with aria-hidden', () => {
|
|
49
|
+
const { container } = render(Select, { props: { options: testOptions } })
|
|
50
|
+
const arrow = container.querySelector('.strand-select__arrow')
|
|
51
|
+
expect(arrow).toBeInTheDocument()
|
|
52
|
+
expect(arrow).toHaveAttribute('aria-hidden', 'true')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not set aria-invalid when no error', () => {
|
|
56
|
+
const { container } = render(Select, { props: { options: testOptions } })
|
|
57
|
+
expect(container.querySelector('.strand-select__field')).not.toHaveAttribute('aria-invalid')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Shape variant */
|
|
4
|
+
export let variant: 'text' | 'rectangle' | 'circle' = 'text'
|
|
5
|
+
/** CSS width value */
|
|
6
|
+
export let width: string | undefined = undefined
|
|
7
|
+
/** CSS height value */
|
|
8
|
+
export let height: string | undefined = undefined
|
|
9
|
+
|
|
10
|
+
$: effectiveWidth = width ?? (variant === 'text' ? '100%' : undefined)
|
|
11
|
+
$: effectiveHeight = variant === 'circle' ? effectiveWidth : height
|
|
12
|
+
|
|
13
|
+
$: classes = [
|
|
14
|
+
'strand-skeleton',
|
|
15
|
+
`strand-skeleton--${variant}`,
|
|
16
|
+
'strand-skeleton--shimmer',
|
|
17
|
+
].filter(Boolean).join(' ')
|
|
18
|
+
|
|
19
|
+
$: inlineStyle = [
|
|
20
|
+
effectiveWidth ? `width: ${effectiveWidth}` : '',
|
|
21
|
+
effectiveHeight ? `height: ${effectiveHeight}` : '',
|
|
22
|
+
].filter(Boolean).join('; ')
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class={classes} aria-hidden="true" style={inlineStyle || undefined} {...$$restProps}></div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Skeleton from './Skeleton.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Skeleton', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Skeleton)
|
|
10
|
+
const el = container.querySelector('.strand-skeleton')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-skeleton--text', 'strand-skeleton--shimmer')
|
|
13
|
+
expect(el).toHaveAttribute('aria-hidden', 'true')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies variant classes', () => {
|
|
17
|
+
const variants = ['text', 'rectangle', 'circle'] as const
|
|
18
|
+
for (const variant of variants) {
|
|
19
|
+
const { container, unmount } = render(Skeleton, { props: { variant } })
|
|
20
|
+
expect(container.querySelector('.strand-skeleton')).toHaveClass(`strand-skeleton--${variant}`)
|
|
21
|
+
unmount()
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('defaults text variant to 100% width', () => {
|
|
26
|
+
const { container } = render(Skeleton)
|
|
27
|
+
const el = container.querySelector('.strand-skeleton') as HTMLElement
|
|
28
|
+
expect(el.style.width).toBe('100%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('applies custom width and height', () => {
|
|
32
|
+
const { container } = render(Skeleton, { props: { variant: 'rectangle', width: '200px', height: '100px' } })
|
|
33
|
+
const el = container.querySelector('.strand-skeleton') as HTMLElement
|
|
34
|
+
expect(el.style.width).toBe('200px')
|
|
35
|
+
expect(el.style.height).toBe('100px')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('circle variant sets height equal to width', () => {
|
|
39
|
+
const { container } = render(Skeleton, { props: { variant: 'circle', width: '48px' } })
|
|
40
|
+
const el = container.querySelector('.strand-skeleton') as HTMLElement
|
|
41
|
+
expect(el.style.width).toBe('48px')
|
|
42
|
+
expect(el.style.height).toBe('48px')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Minimum value */
|
|
4
|
+
export let min: number = 0
|
|
5
|
+
/** Maximum value */
|
|
6
|
+
export let max: number = 100
|
|
7
|
+
/** Step increment */
|
|
8
|
+
export let step: number = 1
|
|
9
|
+
/** Current value */
|
|
10
|
+
export let value: number | undefined = undefined
|
|
11
|
+
/** Disabled state */
|
|
12
|
+
export let disabled: boolean = false
|
|
13
|
+
/** Change handler */
|
|
14
|
+
export let onchange: ((e: Event) => void) | undefined = undefined
|
|
15
|
+
|
|
16
|
+
$: wrapperClasses = [
|
|
17
|
+
'strand-slider',
|
|
18
|
+
disabled && 'strand-slider--disabled',
|
|
19
|
+
].filter(Boolean).join(' ')
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class={wrapperClasses}>
|
|
23
|
+
<input
|
|
24
|
+
type="range"
|
|
25
|
+
class="strand-slider__field"
|
|
26
|
+
{min}
|
|
27
|
+
{max}
|
|
28
|
+
{step}
|
|
29
|
+
{value}
|
|
30
|
+
{disabled}
|
|
31
|
+
aria-valuemin={min}
|
|
32
|
+
aria-valuemax={max}
|
|
33
|
+
aria-valuenow={value}
|
|
34
|
+
on:change={onchange}
|
|
35
|
+
{...$$restProps}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Slider from './Slider.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Slider', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Slider)
|
|
10
|
+
expect(container.querySelector('.strand-slider')).toBeInTheDocument()
|
|
11
|
+
const input = container.querySelector('.strand-slider__field')
|
|
12
|
+
expect(input).toBeInTheDocument()
|
|
13
|
+
expect(input).toHaveAttribute('type', 'range')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies disabled class and attribute', () => {
|
|
17
|
+
const { container } = render(Slider, { props: { disabled: true } })
|
|
18
|
+
expect(container.querySelector('.strand-slider')).toHaveClass('strand-slider--disabled')
|
|
19
|
+
expect(container.querySelector('.strand-slider__field')).toBeDisabled()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('passes min, max, step', () => {
|
|
23
|
+
const { container } = render(Slider, { props: { min: 10, max: 50, step: 5 } })
|
|
24
|
+
const input = container.querySelector('.strand-slider__field')
|
|
25
|
+
expect(input).toHaveAttribute('min', '10')
|
|
26
|
+
expect(input).toHaveAttribute('max', '50')
|
|
27
|
+
expect(input).toHaveAttribute('step', '5')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('sets aria-value attributes', () => {
|
|
31
|
+
const { container } = render(Slider, { props: { min: 0, max: 100, value: 50 } })
|
|
32
|
+
const input = container.querySelector('.strand-slider__field')
|
|
33
|
+
expect(input).toHaveAttribute('aria-valuemin', '0')
|
|
34
|
+
expect(input).toHaveAttribute('aria-valuemax', '100')
|
|
35
|
+
expect(input).toHaveAttribute('aria-valuenow', '50')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('defaults to min=0, max=100, step=1', () => {
|
|
39
|
+
const { container } = render(Slider)
|
|
40
|
+
const input = container.querySelector('.strand-slider__field')
|
|
41
|
+
expect(input).toHaveAttribute('min', '0')
|
|
42
|
+
expect(input).toHaveAttribute('max', '100')
|
|
43
|
+
expect(input).toHaveAttribute('step', '1')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Size of the spinner */
|
|
4
|
+
export let size: 'sm' | 'md' | 'lg' = 'md'
|
|
5
|
+
|
|
6
|
+
$: classes = [
|
|
7
|
+
'strand-spinner',
|
|
8
|
+
`strand-spinner--${size}`,
|
|
9
|
+
].filter(Boolean).join(' ')
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<span class={classes} role="status" {...$$restProps}>
|
|
13
|
+
<span class="strand-spinner__ring" aria-hidden="true"></span>
|
|
14
|
+
<span class="strand-spinner__sr-only">Loading</span>
|
|
15
|
+
</span>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Spinner from './Spinner.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Spinner', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Spinner)
|
|
10
|
+
const el = container.querySelector('.strand-spinner')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-spinner--md')
|
|
13
|
+
expect(el).toHaveAttribute('role', 'status')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('applies size classes', () => {
|
|
17
|
+
const sizes = ['sm', 'md', 'lg'] as const
|
|
18
|
+
for (const size of sizes) {
|
|
19
|
+
const { container, unmount } = render(Spinner, { props: { size } })
|
|
20
|
+
expect(container.querySelector('.strand-spinner')).toHaveClass(`strand-spinner--${size}`)
|
|
21
|
+
unmount()
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('has ring with aria-hidden', () => {
|
|
26
|
+
const { container } = render(Spinner)
|
|
27
|
+
const ring = container.querySelector('.strand-spinner__ring')
|
|
28
|
+
expect(ring).toBeInTheDocument()
|
|
29
|
+
expect(ring).toHaveAttribute('aria-hidden', 'true')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('has screen reader text', () => {
|
|
33
|
+
const { container } = render(Spinner)
|
|
34
|
+
const srOnly = container.querySelector('.strand-spinner__sr-only')
|
|
35
|
+
expect(srOnly).toBeInTheDocument()
|
|
36
|
+
expect(srOnly).toHaveTextContent('Loading')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Flex direction */
|
|
4
|
+
export let direction: 'vertical' | 'horizontal' = 'vertical'
|
|
5
|
+
/** Gap between items, maps to --strand-space-{n} */
|
|
6
|
+
export let gap: number = 4
|
|
7
|
+
/** Cross-axis alignment */
|
|
8
|
+
export let align: 'start' | 'center' | 'end' | 'stretch' = 'stretch'
|
|
9
|
+
/** Main-axis alignment */
|
|
10
|
+
export let justify: 'start' | 'center' | 'end' | 'between' | 'around' | undefined = undefined
|
|
11
|
+
/** Enable flex-wrap */
|
|
12
|
+
export let wrap: boolean = false
|
|
13
|
+
|
|
14
|
+
$: classes = [
|
|
15
|
+
'strand-stack',
|
|
16
|
+
`strand-stack--${direction}`,
|
|
17
|
+
align !== 'stretch' && `strand-stack--align-${align}`,
|
|
18
|
+
justify && `strand-stack--justify-${justify}`,
|
|
19
|
+
wrap && 'strand-stack--wrap',
|
|
20
|
+
].filter(Boolean).join(' ')
|
|
21
|
+
|
|
22
|
+
$: inlineStyle = `gap: var(--strand-space-${gap});`
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class={classes} style={inlineStyle} {...$$restProps}>
|
|
26
|
+
<slot />
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Stack from './Stack.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Stack', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Stack)
|
|
10
|
+
const el = container.querySelector('.strand-stack')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-stack--vertical')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('applies direction class', () => {
|
|
16
|
+
const { container } = render(Stack, { props: { direction: 'horizontal' } })
|
|
17
|
+
expect(container.querySelector('.strand-stack')).toHaveClass('strand-stack--horizontal')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('sets gap via inline style', () => {
|
|
21
|
+
const { container } = render(Stack, { props: { gap: 6 } })
|
|
22
|
+
const el = container.querySelector('.strand-stack') as HTMLElement
|
|
23
|
+
expect(el.style.gap).toBe('var(--strand-space-6)')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('applies align class when not stretch', () => {
|
|
27
|
+
const { container } = render(Stack, { props: { align: 'center' } })
|
|
28
|
+
expect(container.querySelector('.strand-stack')).toHaveClass('strand-stack--align-center')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('does not apply align class for stretch (default)', () => {
|
|
32
|
+
const { container } = render(Stack)
|
|
33
|
+
const el = container.querySelector('.strand-stack')
|
|
34
|
+
expect(el!.className).not.toContain('strand-stack--align')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('applies justify class', () => {
|
|
38
|
+
const { container } = render(Stack, { props: { justify: 'between' } })
|
|
39
|
+
expect(container.querySelector('.strand-stack')).toHaveClass('strand-stack--justify-between')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('applies wrap class', () => {
|
|
43
|
+
const { container } = render(Stack, { props: { wrap: true } })
|
|
44
|
+
expect(container.querySelector('.strand-stack')).toHaveClass('strand-stack--wrap')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
/** Inline label text */
|
|
8
|
+
export let label: string | undefined = undefined
|
|
9
|
+
/** Change handler */
|
|
10
|
+
export let onchange: ((checked: boolean) => void) | undefined = undefined
|
|
11
|
+
|
|
12
|
+
$: classes = [
|
|
13
|
+
'strand-switch',
|
|
14
|
+
checked && 'strand-switch--checked',
|
|
15
|
+
disabled && 'strand-switch--disabled',
|
|
16
|
+
].filter(Boolean).join(' ')
|
|
17
|
+
|
|
18
|
+
function handleClick() {
|
|
19
|
+
if (!disabled) {
|
|
20
|
+
onchange?.(!checked)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
25
|
+
if ((e.key === ' ' || e.key === 'Enter') && !disabled) {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
onchange?.(!checked)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<label class={classes}>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
role="switch"
|
|
36
|
+
class="strand-switch__track"
|
|
37
|
+
aria-checked={checked ? 'true' : 'false'}
|
|
38
|
+
{disabled}
|
|
39
|
+
on:click={handleClick}
|
|
40
|
+
on:keydown={handleKeyDown}
|
|
41
|
+
{...$$restProps}
|
|
42
|
+
>
|
|
43
|
+
<span class="strand-switch__thumb" aria-hidden="true"></span>
|
|
44
|
+
</button>
|
|
45
|
+
{#if label}
|
|
46
|
+
<span class="strand-switch__label">{label}</span>
|
|
47
|
+
{/if}
|
|
48
|
+
</label>
|
|
@@ -0,0 +1,61 @@
|
|
|
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 Switch from './Switch.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Switch', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Switch)
|
|
10
|
+
expect(container.querySelector('.strand-switch')).toBeInTheDocument()
|
|
11
|
+
const btn = container.querySelector('.strand-switch__track')
|
|
12
|
+
expect(btn).toBeInTheDocument()
|
|
13
|
+
expect(btn).toHaveAttribute('role', 'switch')
|
|
14
|
+
expect(btn).toHaveAttribute('aria-checked', 'false')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('applies checked class and aria-checked', () => {
|
|
18
|
+
const { container } = render(Switch, { props: { checked: true } })
|
|
19
|
+
expect(container.querySelector('.strand-switch')).toHaveClass('strand-switch--checked')
|
|
20
|
+
expect(container.querySelector('.strand-switch__track')).toHaveAttribute('aria-checked', 'true')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('applies disabled class and attribute', () => {
|
|
24
|
+
const { container } = render(Switch, { props: { disabled: true } })
|
|
25
|
+
expect(container.querySelector('.strand-switch')).toHaveClass('strand-switch--disabled')
|
|
26
|
+
expect(container.querySelector('.strand-switch__track')).toBeDisabled()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders label text', () => {
|
|
30
|
+
const { container } = render(Switch, { props: { label: 'Dark mode' } })
|
|
31
|
+
const labelEl = container.querySelector('.strand-switch__label')
|
|
32
|
+
expect(labelEl).toBeInTheDocument()
|
|
33
|
+
expect(labelEl).toHaveTextContent('Dark mode')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not render label when not provided', () => {
|
|
37
|
+
const { container } = render(Switch)
|
|
38
|
+
expect(container.querySelector('.strand-switch__label')).not.toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('fires onchange callback on click', async () => {
|
|
42
|
+
const onchange = vi.fn()
|
|
43
|
+
const { container } = render(Switch, { props: { onchange } })
|
|
44
|
+
await fireEvent.click(container.querySelector('.strand-switch__track')!)
|
|
45
|
+
expect(onchange).toHaveBeenCalledWith(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not fire onchange when disabled', async () => {
|
|
49
|
+
const onchange = vi.fn()
|
|
50
|
+
const { container } = render(Switch, { props: { disabled: true, onchange } })
|
|
51
|
+
await fireEvent.click(container.querySelector('.strand-switch__track')!)
|
|
52
|
+
expect(onchange).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('has thumb with aria-hidden', () => {
|
|
56
|
+
const { container } = render(Switch)
|
|
57
|
+
const thumb = container.querySelector('.strand-switch__thumb')
|
|
58
|
+
expect(thumb).toBeInTheDocument()
|
|
59
|
+
expect(thumb).toHaveAttribute('aria-hidden', 'true')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
export interface TableColumn {
|
|
4
|
+
/** Unique key matching the data field */
|
|
5
|
+
key: string
|
|
6
|
+
/** Display header text */
|
|
7
|
+
header: string
|
|
8
|
+
/** Whether the column is sortable */
|
|
9
|
+
sortable?: boolean
|
|
10
|
+
/** Optional fixed width */
|
|
11
|
+
width?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Column definitions */
|
|
15
|
+
export let columns: TableColumn[] = []
|
|
16
|
+
/** Row data */
|
|
17
|
+
export let data: Array<Record<string, any>> = []
|
|
18
|
+
/** Called when a sortable column header is clicked */
|
|
19
|
+
export let onsort: ((key: string, direction: 'asc' | 'desc') => void) | undefined = undefined
|
|
20
|
+
|
|
21
|
+
let sortKey: string | null = null
|
|
22
|
+
let sortDirection: 'asc' | 'desc' = 'asc'
|
|
23
|
+
|
|
24
|
+
function handleSort(key: string) {
|
|
25
|
+
const nextDirection = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc'
|
|
26
|
+
sortKey = key
|
|
27
|
+
sortDirection = nextDirection
|
|
28
|
+
onsort?.(key, nextDirection)
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="strand-table-wrapper" {...$$restProps}>
|
|
33
|
+
<table class="strand-table">
|
|
34
|
+
<thead class="strand-table__head">
|
|
35
|
+
<tr>
|
|
36
|
+
{#each columns as col (col.key)}
|
|
37
|
+
<th class="strand-table__th" style={col.width ? `width: ${col.width}` : undefined}>
|
|
38
|
+
{#if col.sortable}
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
class="strand-table__sort-btn"
|
|
42
|
+
aria-label={`Sort by ${col.header}`}
|
|
43
|
+
on:click={() => handleSort(col.key)}
|
|
44
|
+
>
|
|
45
|
+
{col.header}
|
|
46
|
+
<span class="strand-table__sort-indicator" aria-hidden="true">
|
|
47
|
+
{sortKey === col.key ? (sortDirection === 'asc' ? '\u2191' : '\u2193') : '\u2195'}
|
|
48
|
+
</span>
|
|
49
|
+
</button>
|
|
50
|
+
{:else}
|
|
51
|
+
{col.header}
|
|
52
|
+
{/if}
|
|
53
|
+
</th>
|
|
54
|
+
{/each}
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody class="strand-table__body">
|
|
58
|
+
{#each data as row, rowIndex (rowIndex)}
|
|
59
|
+
<tr class="strand-table__row">
|
|
60
|
+
{#each columns as col (col.key)}
|
|
61
|
+
<td class="strand-table__td">{row[col.key]}</td>
|
|
62
|
+
{/each}
|
|
63
|
+
</tr>
|
|
64
|
+
{/each}
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
</div>
|