@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,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 Card from './Card.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Card', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Card)
|
|
10
|
+
const el = container.querySelector('.strand-card')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-card--elevated', 'strand-card--pad-md')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('applies variant classes', () => {
|
|
16
|
+
const variants = ['elevated', 'outlined', 'interactive'] as const
|
|
17
|
+
for (const variant of variants) {
|
|
18
|
+
const { container, unmount } = render(Card, { props: { variant } })
|
|
19
|
+
expect(container.querySelector('.strand-card')).toHaveClass(`strand-card--${variant}`)
|
|
20
|
+
unmount()
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('applies padding classes', () => {
|
|
25
|
+
const paddings = ['none', 'sm', 'md', 'lg'] as const
|
|
26
|
+
for (const padding of paddings) {
|
|
27
|
+
const { container, unmount } = render(Card, { props: { padding } })
|
|
28
|
+
expect(container.querySelector('.strand-card')).toHaveClass(`strand-card--pad-${padding}`)
|
|
29
|
+
unmount()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Controlled checked state */
|
|
4
|
+
export let checked: boolean = false
|
|
5
|
+
/** Indeterminate visual state */
|
|
6
|
+
export let indeterminate: boolean = false
|
|
7
|
+
/** Disabled state */
|
|
8
|
+
export let disabled: boolean = false
|
|
9
|
+
/** Label text */
|
|
10
|
+
export let label: string | undefined = undefined
|
|
11
|
+
/** Change handler */
|
|
12
|
+
export let onchange: ((e: Event) => void) | undefined = undefined
|
|
13
|
+
|
|
14
|
+
let inputEl: HTMLInputElement
|
|
15
|
+
|
|
16
|
+
$: classes = [
|
|
17
|
+
'strand-checkbox',
|
|
18
|
+
checked && 'strand-checkbox--checked',
|
|
19
|
+
indeterminate && 'strand-checkbox--indeterminate',
|
|
20
|
+
disabled && 'strand-checkbox--disabled',
|
|
21
|
+
].filter(Boolean).join(' ')
|
|
22
|
+
|
|
23
|
+
$: ariaChecked = indeterminate ? 'mixed' : checked ? 'true' : 'false'
|
|
24
|
+
|
|
25
|
+
$: if (inputEl) {
|
|
26
|
+
inputEl.indeterminate = indeterminate
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function handleChange(e: Event) {
|
|
30
|
+
if (!disabled) {
|
|
31
|
+
onchange?.(e)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<label class={classes}>
|
|
37
|
+
<input
|
|
38
|
+
bind:this={inputEl}
|
|
39
|
+
type="checkbox"
|
|
40
|
+
class="strand-checkbox__native"
|
|
41
|
+
{checked}
|
|
42
|
+
{disabled}
|
|
43
|
+
aria-checked={ariaChecked}
|
|
44
|
+
role="checkbox"
|
|
45
|
+
on:change={handleChange}
|
|
46
|
+
{...$$restProps}
|
|
47
|
+
/>
|
|
48
|
+
<span class="strand-checkbox__control" aria-hidden="true">
|
|
49
|
+
{#if indeterminate}
|
|
50
|
+
<svg class="strand-checkbox__icon" viewBox="0 0 16 16" fill="none">
|
|
51
|
+
<line x1="4" y1="8" x2="12" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
52
|
+
</svg>
|
|
53
|
+
{:else if checked}
|
|
54
|
+
<svg class="strand-checkbox__icon" viewBox="0 0 16 16" fill="none">
|
|
55
|
+
<path d="M3.5 8L6.5 11L12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
56
|
+
</svg>
|
|
57
|
+
{/if}
|
|
58
|
+
</span>
|
|
59
|
+
{#if label}
|
|
60
|
+
<span class="strand-checkbox__label">{label}</span>
|
|
61
|
+
{/if}
|
|
62
|
+
</label>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Checkbox from './Checkbox.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Checkbox', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Checkbox)
|
|
10
|
+
expect(container.querySelector('.strand-checkbox')).toBeInTheDocument()
|
|
11
|
+
const input = container.querySelector('.strand-checkbox__native')
|
|
12
|
+
expect(input).toBeInTheDocument()
|
|
13
|
+
expect(input).toHaveAttribute('type', 'checkbox')
|
|
14
|
+
expect(input).toHaveAttribute('role', 'checkbox')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('applies checked class and aria-checked', () => {
|
|
18
|
+
const { container } = render(Checkbox, { props: { checked: true } })
|
|
19
|
+
expect(container.querySelector('.strand-checkbox')).toHaveClass('strand-checkbox--checked')
|
|
20
|
+
expect(container.querySelector('.strand-checkbox__native')).toHaveAttribute('aria-checked', 'true')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('shows checkmark icon when checked', () => {
|
|
24
|
+
const { container } = render(Checkbox, { props: { checked: true } })
|
|
25
|
+
expect(container.querySelector('.strand-checkbox__icon')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies indeterminate class and aria-checked mixed', () => {
|
|
29
|
+
const { container } = render(Checkbox, { props: { indeterminate: true } })
|
|
30
|
+
expect(container.querySelector('.strand-checkbox')).toHaveClass('strand-checkbox--indeterminate')
|
|
31
|
+
expect(container.querySelector('.strand-checkbox__native')).toHaveAttribute('aria-checked', 'mixed')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('shows indeterminate icon', () => {
|
|
35
|
+
const { container } = render(Checkbox, { props: { indeterminate: true } })
|
|
36
|
+
expect(container.querySelector('.strand-checkbox__icon')).toBeInTheDocument()
|
|
37
|
+
expect(container.querySelector('.strand-checkbox__icon line')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('applies disabled class and attribute', () => {
|
|
41
|
+
const { container } = render(Checkbox, { props: { disabled: true } })
|
|
42
|
+
expect(container.querySelector('.strand-checkbox')).toHaveClass('strand-checkbox--disabled')
|
|
43
|
+
expect(container.querySelector('.strand-checkbox__native')).toBeDisabled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders label text', () => {
|
|
47
|
+
const { container } = render(Checkbox, { props: { label: 'Accept terms' } })
|
|
48
|
+
const labelEl = container.querySelector('.strand-checkbox__label')
|
|
49
|
+
expect(labelEl).toBeInTheDocument()
|
|
50
|
+
expect(labelEl).toHaveTextContent('Accept terms')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not render label when not provided', () => {
|
|
54
|
+
const { container } = render(Checkbox)
|
|
55
|
+
expect(container.querySelector('.strand-checkbox__label')).not.toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('control has aria-hidden', () => {
|
|
59
|
+
const { container } = render(Checkbox)
|
|
60
|
+
expect(container.querySelector('.strand-checkbox__control')).toHaveAttribute('aria-hidden', 'true')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('unchecked state has aria-checked false', () => {
|
|
64
|
+
const { container } = render(Checkbox)
|
|
65
|
+
expect(container.querySelector('.strand-checkbox__native')).toHaveAttribute('aria-checked', 'false')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** The code content to display */
|
|
4
|
+
export let code: string
|
|
5
|
+
/** Optional language label (e.g. "html", "css", "bash") */
|
|
6
|
+
export let language: string | undefined = undefined
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="strand-code-block" {...$$restProps}>
|
|
10
|
+
{#if language}
|
|
11
|
+
<span class="strand-code-block__label">{language}</span>
|
|
12
|
+
{/if}
|
|
13
|
+
<pre class="strand-code-block__pre"><code>{code}</code></pre>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import CodeBlock from './CodeBlock.svelte'
|
|
6
|
+
|
|
7
|
+
describe('CodeBlock', () => {
|
|
8
|
+
it('renders with default class', () => {
|
|
9
|
+
const { container } = render(CodeBlock, { props: { code: 'const x = 1' } })
|
|
10
|
+
expect(container.querySelector('.strand-code-block')).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('renders code content', () => {
|
|
14
|
+
const { container } = render(CodeBlock, { props: { code: 'console.log("hi")' } })
|
|
15
|
+
const code = container.querySelector('.strand-code-block__pre code')
|
|
16
|
+
expect(code).toHaveTextContent('console.log("hi")')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shows language label when provided', () => {
|
|
20
|
+
const { container } = render(CodeBlock, { props: { code: 'x = 1', language: 'python' } })
|
|
21
|
+
const label = container.querySelector('.strand-code-block__label')
|
|
22
|
+
expect(label).toBeInTheDocument()
|
|
23
|
+
expect(label).toHaveTextContent('python')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not show label when language is not provided', () => {
|
|
27
|
+
const { container } = render(CodeBlock, { props: { code: 'x = 1' } })
|
|
28
|
+
expect(container.querySelector('.strand-code-block__label')).not.toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders pre and code elements', () => {
|
|
32
|
+
const { container } = render(CodeBlock, { props: { code: 'test' } })
|
|
33
|
+
expect(container.querySelector('.strand-code-block__pre')).toBeInTheDocument()
|
|
34
|
+
expect(container.querySelector('code')).toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Max-width constraint */
|
|
4
|
+
export let size: 'narrow' | 'default' | 'wide' | 'full' = 'default'
|
|
5
|
+
|
|
6
|
+
$: classes = [
|
|
7
|
+
'strand-container',
|
|
8
|
+
`strand-container--${size}`,
|
|
9
|
+
].filter(Boolean).join(' ')
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<div class={classes} {...$$restProps}>
|
|
13
|
+
<slot />
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Container from './Container.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Container', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Container)
|
|
10
|
+
const el = container.querySelector('.strand-container')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-container--default')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('applies size classes', () => {
|
|
16
|
+
const sizes = ['narrow', 'default', 'wide', 'full'] as const
|
|
17
|
+
for (const size of sizes) {
|
|
18
|
+
const { container, unmount } = render(Container, { props: { size } })
|
|
19
|
+
expect(container.querySelector('.strand-container')).toHaveClass(`strand-container--${size}`)
|
|
20
|
+
unmount()
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Overline label text */
|
|
4
|
+
export let label: string
|
|
5
|
+
/** The large displayed value */
|
|
6
|
+
export let value: string | number
|
|
7
|
+
/** Size variant: sm (compact), md (default), lg (hero) */
|
|
8
|
+
export let size: 'sm' | 'md' | 'lg' | undefined = undefined
|
|
9
|
+
|
|
10
|
+
$: classes = [
|
|
11
|
+
'strand-data-readout',
|
|
12
|
+
size && size !== 'md' ? `strand-data-readout--${size}` : '',
|
|
13
|
+
].filter(Boolean).join(' ')
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div class={classes} {...$$restProps}>
|
|
17
|
+
<span class="strand-data-readout__label">{label}</span>
|
|
18
|
+
<span class="strand-data-readout__value">{value}</span>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import DataReadout from './DataReadout.svelte'
|
|
6
|
+
|
|
7
|
+
describe('DataReadout', () => {
|
|
8
|
+
it('renders label and value', () => {
|
|
9
|
+
const { container } = render(DataReadout, { props: { label: 'Revenue', value: '$1.2M' } })
|
|
10
|
+
expect(container.querySelector('.strand-data-readout')).toBeInTheDocument()
|
|
11
|
+
expect(container.querySelector('.strand-data-readout__label')).toHaveTextContent('Revenue')
|
|
12
|
+
expect(container.querySelector('.strand-data-readout__value')).toHaveTextContent('$1.2M')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('renders numeric value', () => {
|
|
16
|
+
const { container } = render(DataReadout, { props: { label: 'Count', value: 42 } })
|
|
17
|
+
expect(container.querySelector('.strand-data-readout__value')).toHaveTextContent('42')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('applies sm size class', () => {
|
|
21
|
+
const { container } = render(DataReadout, { props: { label: 'L', value: 'V', size: 'sm' } })
|
|
22
|
+
expect(container.querySelector('.strand-data-readout')).toHaveClass('strand-data-readout--sm')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('applies lg size class', () => {
|
|
26
|
+
const { container } = render(DataReadout, { props: { label: 'L', value: 'V', size: 'lg' } })
|
|
27
|
+
expect(container.querySelector('.strand-data-readout')).toHaveClass('strand-data-readout--lg')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('does not apply size class for md', () => {
|
|
31
|
+
const { container } = render(DataReadout, { props: { label: 'L', value: 'V', size: 'md' } })
|
|
32
|
+
const el = container.querySelector('.strand-data-readout')
|
|
33
|
+
expect(el!.className).not.toContain('strand-data-readout--md')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { onDestroy, tick } from 'svelte'
|
|
4
|
+
|
|
5
|
+
/** Whether the dialog is open */
|
|
6
|
+
export let open: boolean = false
|
|
7
|
+
/** Optional title rendered in the dialog header */
|
|
8
|
+
export let title: string | undefined = undefined
|
|
9
|
+
/** Close when clicking the backdrop */
|
|
10
|
+
export let closeOnOutsideClick: boolean = true
|
|
11
|
+
/** Close when pressing Escape */
|
|
12
|
+
export let closeOnEscape: boolean = true
|
|
13
|
+
/** Called when the dialog should close */
|
|
14
|
+
export let onclose: (() => void) | undefined = undefined
|
|
15
|
+
|
|
16
|
+
const FOCUSABLE_SELECTOR =
|
|
17
|
+
'a[href], button:not(:disabled), textarea:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
18
|
+
|
|
19
|
+
let dialogIdCounter = 0
|
|
20
|
+
const titleId = `strand-dialog-title-${++dialogIdCounter}`
|
|
21
|
+
|
|
22
|
+
let panelEl: HTMLDivElement
|
|
23
|
+
let previousFocus: Element | null = null
|
|
24
|
+
let originalOverflow = ''
|
|
25
|
+
|
|
26
|
+
$: if (open) {
|
|
27
|
+
onOpen()
|
|
28
|
+
} else {
|
|
29
|
+
onCloseCleanup()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function onOpen() {
|
|
33
|
+
previousFocus = document.activeElement
|
|
34
|
+
originalOverflow = document.body.style.overflow
|
|
35
|
+
document.body.style.overflow = 'hidden'
|
|
36
|
+
|
|
37
|
+
await tick()
|
|
38
|
+
if (panelEl) {
|
|
39
|
+
const focusable = panelEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
40
|
+
if (focusable.length > 0) {
|
|
41
|
+
focusable[0].focus()
|
|
42
|
+
} else {
|
|
43
|
+
panelEl.focus()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onCloseCleanup() {
|
|
49
|
+
if (originalOverflow !== undefined) {
|
|
50
|
+
document.body.style.overflow = originalOverflow
|
|
51
|
+
}
|
|
52
|
+
if (previousFocus && previousFocus instanceof HTMLElement) {
|
|
53
|
+
previousFocus.focus()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
58
|
+
if (e.key === 'Escape' && closeOnEscape) {
|
|
59
|
+
e.stopPropagation()
|
|
60
|
+
onclose?.()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (e.key === 'Tab' && panelEl) {
|
|
65
|
+
const focusable = Array.from(panelEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
|
66
|
+
if (focusable.length === 0) return
|
|
67
|
+
|
|
68
|
+
const first = focusable[0]
|
|
69
|
+
const last = focusable[focusable.length - 1]
|
|
70
|
+
|
|
71
|
+
if (e.shiftKey) {
|
|
72
|
+
if (document.activeElement === first) {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
last.focus()
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
if (document.activeElement === last) {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
first.focus()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleBackdropClick(e: MouseEvent) {
|
|
86
|
+
if (closeOnOutsideClick && e.target === e.currentTarget) {
|
|
87
|
+
onclose?.()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onDestroy(() => {
|
|
92
|
+
if (open) {
|
|
93
|
+
document.body.style.overflow = originalOverflow
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
{#if open}
|
|
99
|
+
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
100
|
+
<div
|
|
101
|
+
class="strand-dialog__backdrop"
|
|
102
|
+
on:click={handleBackdropClick}
|
|
103
|
+
on:keydown={handleKeyDown}
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
bind:this={panelEl}
|
|
107
|
+
class="strand-dialog__panel"
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-labelledby={title ? titleId : undefined}
|
|
111
|
+
tabindex={-1}
|
|
112
|
+
>
|
|
113
|
+
{#if title}
|
|
114
|
+
<div class="strand-dialog__header">
|
|
115
|
+
<h2 id={titleId} class="strand-dialog__title">{title}</h2>
|
|
116
|
+
</div>
|
|
117
|
+
{/if}
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
class="strand-dialog__close"
|
|
121
|
+
aria-label="Close"
|
|
122
|
+
on:click={() => onclose?.()}
|
|
123
|
+
>
|
|
124
|
+
×
|
|
125
|
+
</button>
|
|
126
|
+
<div class="strand-dialog__body">
|
|
127
|
+
<slot />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
{/if}
|
|
@@ -0,0 +1,77 @@
|
|
|
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 Dialog from './Dialog.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Dialog', () => {
|
|
8
|
+
it('does not render when closed', () => {
|
|
9
|
+
const { container } = render(Dialog, { props: { open: false } })
|
|
10
|
+
expect(container.querySelector('.strand-dialog__backdrop')).not.toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('renders backdrop and panel when open', () => {
|
|
14
|
+
const { container } = render(Dialog, { props: { open: true } })
|
|
15
|
+
expect(container.querySelector('.strand-dialog__backdrop')).toBeInTheDocument()
|
|
16
|
+
const panel = container.querySelector('.strand-dialog__panel')
|
|
17
|
+
expect(panel).toBeInTheDocument()
|
|
18
|
+
expect(panel).toHaveAttribute('role', 'dialog')
|
|
19
|
+
expect(panel).toHaveAttribute('aria-modal', 'true')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders title when provided', () => {
|
|
23
|
+
const { container } = render(Dialog, { props: { open: true, title: 'Confirm' } })
|
|
24
|
+
const header = container.querySelector('.strand-dialog__header')
|
|
25
|
+
expect(header).toBeInTheDocument()
|
|
26
|
+
const titleEl = container.querySelector('.strand-dialog__title')
|
|
27
|
+
expect(titleEl).toHaveTextContent('Confirm')
|
|
28
|
+
const panel = container.querySelector('.strand-dialog__panel')
|
|
29
|
+
expect(panel).toHaveAttribute('aria-labelledby', titleEl!.id)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not render title when not provided', () => {
|
|
33
|
+
const { container } = render(Dialog, { props: { open: true } })
|
|
34
|
+
expect(container.querySelector('.strand-dialog__header')).not.toBeInTheDocument()
|
|
35
|
+
expect(container.querySelector('.strand-dialog__panel')).not.toHaveAttribute('aria-labelledby')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('has close button', () => {
|
|
39
|
+
const { container } = render(Dialog, { props: { open: true } })
|
|
40
|
+
const close = container.querySelector('.strand-dialog__close')
|
|
41
|
+
expect(close).toBeInTheDocument()
|
|
42
|
+
expect(close).toHaveAttribute('aria-label', 'Close')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('fires onclose on close button click', async () => {
|
|
46
|
+
const onclose = vi.fn()
|
|
47
|
+
const { container } = render(Dialog, { props: { open: true, onclose } })
|
|
48
|
+
await fireEvent.click(container.querySelector('.strand-dialog__close')!)
|
|
49
|
+
expect(onclose).toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('fires onclose on backdrop click', async () => {
|
|
53
|
+
const onclose = vi.fn()
|
|
54
|
+
const { container } = render(Dialog, { props: { open: true, onclose } })
|
|
55
|
+
await fireEvent.click(container.querySelector('.strand-dialog__backdrop')!)
|
|
56
|
+
expect(onclose).toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('fires onclose on Escape key', async () => {
|
|
60
|
+
const onclose = vi.fn()
|
|
61
|
+
const { container } = render(Dialog, { props: { open: true, onclose } })
|
|
62
|
+
await fireEvent.keyDown(container.querySelector('.strand-dialog__backdrop')!, { key: 'Escape' })
|
|
63
|
+
expect(onclose).toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not fire onclose on Escape when closeOnEscape is false', async () => {
|
|
67
|
+
const onclose = vi.fn()
|
|
68
|
+
const { container } = render(Dialog, { props: { open: true, closeOnEscape: false, onclose } })
|
|
69
|
+
await fireEvent.keyDown(container.querySelector('.strand-dialog__backdrop')!, { key: 'Escape' })
|
|
70
|
+
expect(onclose).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('has dialog body', () => {
|
|
74
|
+
const { container } = render(Dialog, { props: { open: true } })
|
|
75
|
+
expect(container.querySelector('.strand-dialog__body')).toBeInTheDocument()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Separator direction */
|
|
4
|
+
export let direction: 'horizontal' | 'vertical' = 'horizontal'
|
|
5
|
+
/** Optional label text displayed in the middle of the line */
|
|
6
|
+
export let label: string | undefined = undefined
|
|
7
|
+
|
|
8
|
+
$: isVertical = direction === 'vertical'
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if isVertical}
|
|
12
|
+
<div
|
|
13
|
+
role="separator"
|
|
14
|
+
aria-orientation="vertical"
|
|
15
|
+
class={['strand-divider', 'strand-divider--vertical'].join(' ')}
|
|
16
|
+
{...$$restProps}
|
|
17
|
+
></div>
|
|
18
|
+
{:else if label}
|
|
19
|
+
<div
|
|
20
|
+
role="separator"
|
|
21
|
+
aria-orientation="horizontal"
|
|
22
|
+
class={['strand-divider', 'strand-divider--horizontal', 'strand-divider--labeled'].join(' ')}
|
|
23
|
+
{...$$restProps}
|
|
24
|
+
>
|
|
25
|
+
<span class="strand-divider__line"></span>
|
|
26
|
+
<span class="strand-divider__label">{label}</span>
|
|
27
|
+
<span class="strand-divider__line"></span>
|
|
28
|
+
</div>
|
|
29
|
+
{:else}
|
|
30
|
+
<hr
|
|
31
|
+
role="separator"
|
|
32
|
+
aria-orientation="horizontal"
|
|
33
|
+
class={['strand-divider', 'strand-divider--horizontal'].join(' ')}
|
|
34
|
+
{...$$restProps}
|
|
35
|
+
/>
|
|
36
|
+
{/if}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Divider from './Divider.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Divider', () => {
|
|
8
|
+
it('renders horizontal hr by default', () => {
|
|
9
|
+
const { container } = render(Divider)
|
|
10
|
+
const el = container.querySelector('.strand-divider')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el!.tagName).toBe('HR')
|
|
13
|
+
expect(el).toHaveClass('strand-divider--horizontal')
|
|
14
|
+
expect(el).toHaveAttribute('role', 'separator')
|
|
15
|
+
expect(el).toHaveAttribute('aria-orientation', 'horizontal')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('renders vertical divider', () => {
|
|
19
|
+
const { container } = render(Divider, { props: { direction: 'vertical' } })
|
|
20
|
+
const el = container.querySelector('.strand-divider')
|
|
21
|
+
expect(el).toHaveClass('strand-divider--vertical')
|
|
22
|
+
expect(el).toHaveAttribute('aria-orientation', 'vertical')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders labeled horizontal divider', () => {
|
|
26
|
+
const { container } = render(Divider, { props: { label: 'OR' } })
|
|
27
|
+
const el = container.querySelector('.strand-divider')
|
|
28
|
+
expect(el).toHaveClass('strand-divider--labeled')
|
|
29
|
+
expect(el!.tagName).toBe('DIV')
|
|
30
|
+
expect(container.querySelector('.strand-divider__label')).toHaveTextContent('OR')
|
|
31
|
+
const lines = container.querySelectorAll('.strand-divider__line')
|
|
32
|
+
expect(lines.length).toBe(2)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Label text */
|
|
4
|
+
export let label: string
|
|
5
|
+
/** Associates the label with a form control */
|
|
6
|
+
export let htmlFor: string
|
|
7
|
+
/** Hint text displayed below the input */
|
|
8
|
+
export let hint: string | undefined = undefined
|
|
9
|
+
/** Error text displayed below the input (replaces hint) */
|
|
10
|
+
export let error: string | undefined = undefined
|
|
11
|
+
/** Show required indicator */
|
|
12
|
+
export let required: boolean = false
|
|
13
|
+
|
|
14
|
+
$: classes = [
|
|
15
|
+
'strand-form-field',
|
|
16
|
+
error && 'strand-form-field--error',
|
|
17
|
+
].filter(Boolean).join(' ')
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<div class={classes} {...$$restProps}>
|
|
21
|
+
<label class="strand-form-field__label" for={htmlFor}>
|
|
22
|
+
{label}
|
|
23
|
+
{#if required}
|
|
24
|
+
<span class="strand-form-field__required" aria-hidden="true">*</span>
|
|
25
|
+
{/if}
|
|
26
|
+
</label>
|
|
27
|
+
<div class="strand-form-field__control">
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
{#if error}
|
|
31
|
+
<p class="strand-form-field__error" id={`${htmlFor}-error`} role="alert">
|
|
32
|
+
{error}
|
|
33
|
+
</p>
|
|
34
|
+
{:else if hint}
|
|
35
|
+
<p class="strand-form-field__hint" id={`${htmlFor}-hint`}>
|
|
36
|
+
{hint}
|
|
37
|
+
</p>
|
|
38
|
+
{/if}
|
|
39
|
+
</div>
|