@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,88 @@
|
|
|
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 Table from './Table.svelte'
|
|
6
|
+
|
|
7
|
+
const testColumns = [
|
|
8
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
9
|
+
{ key: 'age', header: 'Age' },
|
|
10
|
+
{ key: 'role', header: 'Role', sortable: true, width: '200px' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const testData = [
|
|
14
|
+
{ name: 'Alice', age: 30, role: 'Engineer' },
|
|
15
|
+
{ name: 'Bob', age: 25, role: 'Designer' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
describe('Table', () => {
|
|
19
|
+
it('renders with wrapper and table', () => {
|
|
20
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
21
|
+
expect(container.querySelector('.strand-table-wrapper')).toBeInTheDocument()
|
|
22
|
+
expect(container.querySelector('.strand-table')).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders column headers', () => {
|
|
26
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
27
|
+
const headers = container.querySelectorAll('.strand-table__th')
|
|
28
|
+
expect(headers.length).toBe(3)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders data rows', () => {
|
|
32
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
33
|
+
const rows = container.querySelectorAll('.strand-table__row')
|
|
34
|
+
expect(rows.length).toBe(2)
|
|
35
|
+
const cells = rows[0].querySelectorAll('.strand-table__td')
|
|
36
|
+
expect(cells[0]).toHaveTextContent('Alice')
|
|
37
|
+
expect(cells[1]).toHaveTextContent('30')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders sort button for sortable columns', () => {
|
|
41
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
42
|
+
const sortBtns = container.querySelectorAll('.strand-table__sort-btn')
|
|
43
|
+
expect(sortBtns.length).toBe(2)
|
|
44
|
+
expect(sortBtns[0]).toHaveAttribute('aria-label', 'Sort by Name')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not render sort button for non-sortable columns', () => {
|
|
48
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
49
|
+
const headers = container.querySelectorAll('.strand-table__th')
|
|
50
|
+
expect(headers[1].querySelector('.strand-table__sort-btn')).not.toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('shows sort indicator', () => {
|
|
54
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
55
|
+
const indicator = container.querySelector('.strand-table__sort-indicator')
|
|
56
|
+
expect(indicator).toBeInTheDocument()
|
|
57
|
+
expect(indicator).toHaveAttribute('aria-hidden', 'true')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('fires onsort callback with key and direction', async () => {
|
|
61
|
+
const onsort = vi.fn()
|
|
62
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData, onsort } })
|
|
63
|
+
const sortBtns = container.querySelectorAll('.strand-table__sort-btn')
|
|
64
|
+
await fireEvent.click(sortBtns[0])
|
|
65
|
+
expect(onsort).toHaveBeenCalledWith('name', 'asc')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('toggles sort direction on second click', async () => {
|
|
69
|
+
const onsort = vi.fn()
|
|
70
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData, onsort } })
|
|
71
|
+
const sortBtns = container.querySelectorAll('.strand-table__sort-btn')
|
|
72
|
+
await fireEvent.click(sortBtns[0])
|
|
73
|
+
await fireEvent.click(sortBtns[0])
|
|
74
|
+
expect(onsort).toHaveBeenNthCalledWith(2, 'name', 'desc')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('applies column width', () => {
|
|
78
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
79
|
+
const headers = container.querySelectorAll('.strand-table__th')
|
|
80
|
+
expect((headers[2] as HTMLElement).style.width).toBe('200px')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('has thead and tbody', () => {
|
|
84
|
+
const { container } = render(Table, { props: { columns: testColumns, data: testData } })
|
|
85
|
+
expect(container.querySelector('.strand-table__head')).toBeInTheDocument()
|
|
86
|
+
expect(container.querySelector('.strand-table__body')).toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
export interface TabItem {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Tab definitions */
|
|
9
|
+
export let tabs: TabItem[] = []
|
|
10
|
+
/** Currently active tab id (controlled) */
|
|
11
|
+
export let activeTab: string
|
|
12
|
+
/** Called when active tab changes */
|
|
13
|
+
export let onchange: ((id: string) => void) | undefined = undefined
|
|
14
|
+
|
|
15
|
+
let tablistEl: HTMLDivElement
|
|
16
|
+
|
|
17
|
+
function handleTabClick(id: string) {
|
|
18
|
+
onchange?.(id)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function focusAndSelect(index: number) {
|
|
22
|
+
const tab = tabs[index]
|
|
23
|
+
if (tab) {
|
|
24
|
+
onchange?.(tab.id)
|
|
25
|
+
const buttons = tablistEl?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
|
26
|
+
buttons?.[index]?.focus()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
31
|
+
const currentIndex = tabs.findIndex((t) => t.id === activeTab)
|
|
32
|
+
let nextIndex: number | null = null
|
|
33
|
+
|
|
34
|
+
switch (e.key) {
|
|
35
|
+
case 'ArrowRight':
|
|
36
|
+
nextIndex = (currentIndex + 1) % tabs.length
|
|
37
|
+
break
|
|
38
|
+
case 'ArrowLeft':
|
|
39
|
+
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
|
40
|
+
break
|
|
41
|
+
case 'Home':
|
|
42
|
+
nextIndex = 0
|
|
43
|
+
break
|
|
44
|
+
case 'End':
|
|
45
|
+
nextIndex = tabs.length - 1
|
|
46
|
+
break
|
|
47
|
+
default:
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
focusAndSelect(nextIndex)
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<div class="strand-tabs" {...$$restProps}>
|
|
57
|
+
<div bind:this={tablistEl} role="tablist" on:keydown={handleKeyDown}>
|
|
58
|
+
{#each tabs as tab (tab.id)}
|
|
59
|
+
{@const isActive = tab.id === activeTab}
|
|
60
|
+
<button
|
|
61
|
+
id={`tab-${tab.id}`}
|
|
62
|
+
role="tab"
|
|
63
|
+
type="button"
|
|
64
|
+
class={['strand-tabs__tab', isActive && 'strand-tabs__tab--active'].filter(Boolean).join(' ')}
|
|
65
|
+
aria-selected={isActive ? 'true' : 'false'}
|
|
66
|
+
aria-controls={`panel-${tab.id}`}
|
|
67
|
+
tabindex={isActive ? 0 : -1}
|
|
68
|
+
on:click={() => handleTabClick(tab.id)}
|
|
69
|
+
>
|
|
70
|
+
{tab.label}
|
|
71
|
+
</button>
|
|
72
|
+
{/each}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{#each tabs as tab (tab.id)}
|
|
76
|
+
{@const isActive = tab.id === activeTab}
|
|
77
|
+
<div
|
|
78
|
+
id={`panel-${tab.id}`}
|
|
79
|
+
role="tabpanel"
|
|
80
|
+
aria-labelledby={`tab-${tab.id}`}
|
|
81
|
+
hidden={!isActive}
|
|
82
|
+
tabindex={0}
|
|
83
|
+
>
|
|
84
|
+
{#if isActive}
|
|
85
|
+
<slot />
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
{/each}
|
|
89
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
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 Tabs from './Tabs.svelte'
|
|
6
|
+
|
|
7
|
+
const testTabs = [
|
|
8
|
+
{ id: 'one', label: 'Tab One' },
|
|
9
|
+
{ id: 'two', label: 'Tab Two' },
|
|
10
|
+
{ id: 'three', label: 'Tab Three' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('Tabs', () => {
|
|
14
|
+
it('renders with tablist role', () => {
|
|
15
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one' } })
|
|
16
|
+
expect(container.querySelector('.strand-tabs')).toBeInTheDocument()
|
|
17
|
+
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders tab buttons', () => {
|
|
21
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one' } })
|
|
22
|
+
const buttons = container.querySelectorAll('[role="tab"]')
|
|
23
|
+
expect(buttons.length).toBe(3)
|
|
24
|
+
expect(buttons[0]).toHaveTextContent('Tab One')
|
|
25
|
+
expect(buttons[1]).toHaveTextContent('Tab Two')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('marks active tab', () => {
|
|
29
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'two' } })
|
|
30
|
+
const buttons = container.querySelectorAll('[role="tab"]')
|
|
31
|
+
expect(buttons[0]).toHaveAttribute('aria-selected', 'false')
|
|
32
|
+
expect(buttons[1]).toHaveAttribute('aria-selected', 'true')
|
|
33
|
+
expect(buttons[1]).toHaveClass('strand-tabs__tab--active')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('sets tabindex on active and inactive tabs', () => {
|
|
37
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one' } })
|
|
38
|
+
const buttons = container.querySelectorAll('[role="tab"]')
|
|
39
|
+
expect(buttons[0]).toHaveAttribute('tabindex', '0')
|
|
40
|
+
expect(buttons[1]).toHaveAttribute('tabindex', '-1')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders panels with correct ARIA', () => {
|
|
44
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one' } })
|
|
45
|
+
const panels = container.querySelectorAll('[role="tabpanel"]')
|
|
46
|
+
expect(panels.length).toBe(3)
|
|
47
|
+
expect(panels[0]).toHaveAttribute('aria-labelledby', 'tab-one')
|
|
48
|
+
expect(panels[0]).not.toHaveAttribute('hidden')
|
|
49
|
+
expect(panels[1]).toHaveAttribute('hidden', '')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('fires onchange callback on tab click', async () => {
|
|
53
|
+
const onchange = vi.fn()
|
|
54
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one', onchange } })
|
|
55
|
+
const buttons = container.querySelectorAll('[role="tab"]')
|
|
56
|
+
await fireEvent.click(buttons[1])
|
|
57
|
+
expect(onchange).toHaveBeenCalledWith('two')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('sets aria-controls on tabs', () => {
|
|
61
|
+
const { container } = render(Tabs, { props: { tabs: testTabs, activeTab: 'one' } })
|
|
62
|
+
const buttons = container.querySelectorAll('[role="tab"]')
|
|
63
|
+
expect(buttons[0]).toHaveAttribute('aria-controls', 'panel-one')
|
|
64
|
+
expect(buttons[0]).toHaveAttribute('id', 'tab-one')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Visual style variant */
|
|
4
|
+
export let variant: 'solid' | 'outlined' = 'solid'
|
|
5
|
+
/** Color status */
|
|
6
|
+
export let status: 'default' | 'teal' | 'blue' | 'amber' | 'red' = 'default'
|
|
7
|
+
/** Show remove button */
|
|
8
|
+
export let removable: boolean = false
|
|
9
|
+
/** Called when remove button is clicked */
|
|
10
|
+
export let onremove: (() => void) | undefined = undefined
|
|
11
|
+
|
|
12
|
+
$: classes = [
|
|
13
|
+
'strand-tag',
|
|
14
|
+
`strand-tag--${variant}`,
|
|
15
|
+
`strand-tag--${status}`,
|
|
16
|
+
].filter(Boolean).join(' ')
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<span class={classes} {...$$restProps}>
|
|
20
|
+
<span class="strand-tag__text"><slot /></span>
|
|
21
|
+
{#if removable}
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="strand-tag__remove"
|
|
25
|
+
aria-label="Remove"
|
|
26
|
+
on:click={() => onremove?.()}
|
|
27
|
+
>
|
|
28
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
29
|
+
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
30
|
+
</svg>
|
|
31
|
+
</button>
|
|
32
|
+
{/if}
|
|
33
|
+
</span>
|
|
@@ -0,0 +1,63 @@
|
|
|
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 Tag from './Tag.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Tag', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Tag)
|
|
10
|
+
const el = container.querySelector('.strand-tag')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-tag--solid', 'strand-tag--default')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('applies variant classes', () => {
|
|
16
|
+
const variants = ['solid', 'outlined'] as const
|
|
17
|
+
for (const variant of variants) {
|
|
18
|
+
const { container, unmount } = render(Tag, { props: { variant } })
|
|
19
|
+
expect(container.querySelector('.strand-tag')).toHaveClass(`strand-tag--${variant}`)
|
|
20
|
+
unmount()
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('applies status classes', () => {
|
|
25
|
+
const statuses = ['default', 'teal', 'blue', 'amber', 'red'] as const
|
|
26
|
+
for (const status of statuses) {
|
|
27
|
+
const { container, unmount } = render(Tag, { props: { status } })
|
|
28
|
+
expect(container.querySelector('.strand-tag')).toHaveClass(`strand-tag--${status}`)
|
|
29
|
+
unmount()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders text content in .strand-tag__text', () => {
|
|
34
|
+
const { container } = render(Tag)
|
|
35
|
+
expect(container.querySelector('.strand-tag__text')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows remove button when removable', () => {
|
|
39
|
+
const { container } = render(Tag, { props: { removable: true } })
|
|
40
|
+
const btn = container.querySelector('.strand-tag__remove')
|
|
41
|
+
expect(btn).toBeInTheDocument()
|
|
42
|
+
expect(btn).toHaveAttribute('aria-label', 'Remove')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not show remove button by default', () => {
|
|
46
|
+
const { container } = render(Tag)
|
|
47
|
+
expect(container.querySelector('.strand-tag__remove')).not.toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('fires onremove callback on button click', async () => {
|
|
51
|
+
const onremove = vi.fn()
|
|
52
|
+
const { container } = render(Tag, { props: { removable: true, onremove } })
|
|
53
|
+
const btn = container.querySelector('.strand-tag__remove')!
|
|
54
|
+
await fireEvent.click(btn)
|
|
55
|
+
expect(onremove).toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('remove button svg is aria-hidden', () => {
|
|
59
|
+
const { container } = render(Tag, { props: { removable: true } })
|
|
60
|
+
const svg = container.querySelector('.strand-tag__remove svg')
|
|
61
|
+
expect(svg).toHaveAttribute('aria-hidden', 'true')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/** Auto-resize to fit content */
|
|
4
|
+
export let autoResize: boolean = false
|
|
5
|
+
/** Show character count (requires maxLength) */
|
|
6
|
+
export let showCount: boolean = false
|
|
7
|
+
/** Show error styling */
|
|
8
|
+
export let error: boolean = false
|
|
9
|
+
/** Maximum character count */
|
|
10
|
+
export let maxLength: number | undefined = undefined
|
|
11
|
+
/** Disabled state */
|
|
12
|
+
export let disabled: boolean = false
|
|
13
|
+
/** Controlled value */
|
|
14
|
+
export let value: string = ''
|
|
15
|
+
|
|
16
|
+
let textareaEl: HTMLTextAreaElement
|
|
17
|
+
|
|
18
|
+
$: wrapperClasses = [
|
|
19
|
+
'strand-textarea',
|
|
20
|
+
error && 'strand-textarea--error',
|
|
21
|
+
disabled && 'strand-textarea--disabled',
|
|
22
|
+
autoResize && 'strand-textarea--auto-resize',
|
|
23
|
+
].filter(Boolean).join(' ')
|
|
24
|
+
|
|
25
|
+
$: currentLength = typeof value === 'string' ? value.length : 0
|
|
26
|
+
|
|
27
|
+
function handleInput(e: Event) {
|
|
28
|
+
const target = e.target as HTMLTextAreaElement
|
|
29
|
+
value = target.value
|
|
30
|
+
if (autoResize && textareaEl) {
|
|
31
|
+
textareaEl.style.height = 'auto'
|
|
32
|
+
textareaEl.style.height = `${textareaEl.scrollHeight}px`
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div class={wrapperClasses}>
|
|
38
|
+
<textarea
|
|
39
|
+
bind:this={textareaEl}
|
|
40
|
+
class="strand-textarea__field"
|
|
41
|
+
{disabled}
|
|
42
|
+
aria-invalid={error ? 'true' : undefined}
|
|
43
|
+
maxlength={maxLength}
|
|
44
|
+
{value}
|
|
45
|
+
on:input={handleInput}
|
|
46
|
+
{...$$restProps}
|
|
47
|
+
></textarea>
|
|
48
|
+
{#if showCount && maxLength != null}
|
|
49
|
+
<span class="strand-textarea__count" aria-live="polite">
|
|
50
|
+
{currentLength}/{maxLength}
|
|
51
|
+
</span>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { render } from '@testing-library/svelte'
|
|
5
|
+
import Textarea from './Textarea.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Textarea', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Textarea)
|
|
10
|
+
expect(container.querySelector('.strand-textarea')).toBeInTheDocument()
|
|
11
|
+
expect(container.querySelector('.strand-textarea__field')).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('applies error class and aria-invalid', () => {
|
|
15
|
+
const { container } = render(Textarea, { props: { error: true } })
|
|
16
|
+
expect(container.querySelector('.strand-textarea')).toHaveClass('strand-textarea--error')
|
|
17
|
+
expect(container.querySelector('.strand-textarea__field')).toHaveAttribute('aria-invalid', 'true')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('applies disabled class and attribute', () => {
|
|
21
|
+
const { container } = render(Textarea, { props: { disabled: true } })
|
|
22
|
+
expect(container.querySelector('.strand-textarea')).toHaveClass('strand-textarea--disabled')
|
|
23
|
+
expect(container.querySelector('.strand-textarea__field')).toBeDisabled()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('applies auto-resize class', () => {
|
|
27
|
+
const { container } = render(Textarea, { props: { autoResize: true } })
|
|
28
|
+
expect(container.querySelector('.strand-textarea')).toHaveClass('strand-textarea--auto-resize')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shows character count when showCount and maxLength', () => {
|
|
32
|
+
const { container } = render(Textarea, { props: { showCount: true, maxLength: 100, value: 'hello' } })
|
|
33
|
+
const count = container.querySelector('.strand-textarea__count')
|
|
34
|
+
expect(count).toBeInTheDocument()
|
|
35
|
+
expect(count).toHaveTextContent('5/100')
|
|
36
|
+
expect(count).toHaveAttribute('aria-live', 'polite')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not show count without maxLength', () => {
|
|
40
|
+
const { container } = render(Textarea, { props: { showCount: true } })
|
|
41
|
+
expect(container.querySelector('.strand-textarea__count')).not.toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not show count when showCount is false', () => {
|
|
45
|
+
const { container } = render(Textarea, { props: { maxLength: 100 } })
|
|
46
|
+
expect(container.querySelector('.strand-textarea__count')).not.toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not set aria-invalid when no error', () => {
|
|
50
|
+
const { container } = render(Textarea)
|
|
51
|
+
expect(container.querySelector('.strand-textarea__field')).not.toHaveAttribute('aria-invalid')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import type { ToastStatus } from './useToast'
|
|
4
|
+
|
|
5
|
+
/** Visual status */
|
|
6
|
+
export let status: ToastStatus = 'info'
|
|
7
|
+
/** Toast message text */
|
|
8
|
+
export let message: string
|
|
9
|
+
/** Called when dismiss button is clicked */
|
|
10
|
+
export let ondismiss: (() => void) | undefined = undefined
|
|
11
|
+
|
|
12
|
+
$: isUrgent = status === 'error' || status === 'warning'
|
|
13
|
+
$: classes = [
|
|
14
|
+
'strand-toast',
|
|
15
|
+
`strand-toast--${status}`,
|
|
16
|
+
].filter(Boolean).join(' ')
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<div class={classes} role="status" aria-live={isUrgent ? 'assertive' : 'polite'} {...$$restProps}>
|
|
20
|
+
<span class="strand-toast__message">{message}</span>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
class="strand-toast__dismiss"
|
|
24
|
+
aria-label="Dismiss"
|
|
25
|
+
on:click={() => ondismiss?.()}
|
|
26
|
+
>
|
|
27
|
+
×
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,60 @@
|
|
|
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 Toast from './Toast.svelte'
|
|
6
|
+
|
|
7
|
+
describe('Toast', () => {
|
|
8
|
+
it('renders with default classes', () => {
|
|
9
|
+
const { container } = render(Toast, { props: { message: 'Hello' } })
|
|
10
|
+
const el = container.querySelector('.strand-toast')
|
|
11
|
+
expect(el).toBeInTheDocument()
|
|
12
|
+
expect(el).toHaveClass('strand-toast--info')
|
|
13
|
+
expect(el).toHaveAttribute('role', 'status')
|
|
14
|
+
expect(el).toHaveAttribute('aria-live', 'polite')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders message text', () => {
|
|
18
|
+
const { container } = render(Toast, { props: { message: 'Saved!' } })
|
|
19
|
+
expect(container.querySelector('.strand-toast__message')).toHaveTextContent('Saved!')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('applies status classes', () => {
|
|
23
|
+
const statuses = ['info', 'success', 'warning', 'error'] as const
|
|
24
|
+
for (const status of statuses) {
|
|
25
|
+
const { container, unmount } = render(Toast, { props: { message: 'Test', status } })
|
|
26
|
+
expect(container.querySelector('.strand-toast')).toHaveClass(`strand-toast--${status}`)
|
|
27
|
+
unmount()
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uses assertive aria-live for error and warning', () => {
|
|
32
|
+
for (const status of ['error', 'warning'] as const) {
|
|
33
|
+
const { container, unmount } = render(Toast, { props: { message: 'Test', status } })
|
|
34
|
+
expect(container.querySelector('.strand-toast')).toHaveAttribute('aria-live', 'assertive')
|
|
35
|
+
unmount()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('uses polite aria-live for info and success', () => {
|
|
40
|
+
for (const status of ['info', 'success'] as const) {
|
|
41
|
+
const { container, unmount } = render(Toast, { props: { message: 'Test', status } })
|
|
42
|
+
expect(container.querySelector('.strand-toast')).toHaveAttribute('aria-live', 'polite')
|
|
43
|
+
unmount()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('has dismiss button with aria-label', () => {
|
|
48
|
+
const { container } = render(Toast, { props: { message: 'Test' } })
|
|
49
|
+
const btn = container.querySelector('.strand-toast__dismiss')
|
|
50
|
+
expect(btn).toBeInTheDocument()
|
|
51
|
+
expect(btn).toHaveAttribute('aria-label', 'Dismiss')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('fires ondismiss callback', async () => {
|
|
55
|
+
const ondismiss = vi.fn()
|
|
56
|
+
const { container } = render(Toast, { props: { message: 'Test', ondismiss } })
|
|
57
|
+
await fireEvent.click(container.querySelector('.strand-toast__dismiss')!)
|
|
58
|
+
expect(ondismiss).toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!--! Strand Svelte | MIT License | dillingerstaffing.com -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { onDestroy } from 'svelte'
|
|
4
|
+
import { createToastContext } from './useToast'
|
|
5
|
+
import type { ToastEntry } from './useToast'
|
|
6
|
+
|
|
7
|
+
const ctx = createToastContext()
|
|
8
|
+
const { toasts, removeToast } = ctx
|
|
9
|
+
|
|
10
|
+
let toastList: ToastEntry[] = []
|
|
11
|
+
const unsubscribe = toasts.subscribe((value) => {
|
|
12
|
+
toastList = value
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function isUrgent(status: string): boolean {
|
|
16
|
+
return status === 'error' || status === 'warning'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onDestroy(() => {
|
|
20
|
+
unsubscribe()
|
|
21
|
+
})
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<slot />
|
|
25
|
+
{#if toastList.length > 0}
|
|
26
|
+
<div class="strand-toast__container">
|
|
27
|
+
{#each toastList as entry (entry.id)}
|
|
28
|
+
<div
|
|
29
|
+
class={['strand-toast', `strand-toast--${entry.status}`].join(' ')}
|
|
30
|
+
role="status"
|
|
31
|
+
aria-live={isUrgent(entry.status) ? 'assertive' : 'polite'}
|
|
32
|
+
>
|
|
33
|
+
<span class="strand-toast__message">{entry.message}</span>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
class="strand-toast__dismiss"
|
|
37
|
+
aria-label="Dismiss"
|
|
38
|
+
on:click={() => removeToast(entry.id)}
|
|
39
|
+
>
|
|
40
|
+
×
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
{/each}
|
|
44
|
+
</div>
|
|
45
|
+
{/if}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/*! Strand Svelte | MIT License | dillingerstaffing.com */
|
|
2
|
+
export { default as Toast } from './Toast.svelte'
|
|
3
|
+
export { default as ToastProvider } from './ToastProvider.svelte'
|
|
4
|
+
export { createToastContext, getToastContext } from './useToast'
|
|
5
|
+
export type { ToastStatus, ToastOptions, ToastEntry, ToastContextValue } from './useToast'
|