@dillingerstaffing/strand-svelte 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +56 -0
  2. package/dist/css/strand-ui.css +2583 -0
  3. package/dist/index.js +4154 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +53 -0
  6. package/src/components/Alert/Alert.svelte +32 -0
  7. package/src/components/Alert/Alert.test.ts +64 -0
  8. package/src/components/Alert/index.ts +2 -0
  9. package/src/components/Avatar/Avatar.svelte +40 -0
  10. package/src/components/Avatar/Avatar.test.ts +55 -0
  11. package/src/components/Avatar/index.ts +2 -0
  12. package/src/components/Badge/Badge.svelte +41 -0
  13. package/src/components/Badge/Badge.test.ts +55 -0
  14. package/src/components/Badge/index.ts +2 -0
  15. package/src/components/Breadcrumb/Breadcrumb.svelte +29 -0
  16. package/src/components/Breadcrumb/Breadcrumb.test.ts +66 -0
  17. package/src/components/Breadcrumb/index.ts +2 -0
  18. package/src/components/Button/Button.svelte +55 -0
  19. package/src/components/Button/Button.test.ts +110 -0
  20. package/src/components/Button/index.ts +2 -0
  21. package/src/components/Card/Card.svelte +17 -0
  22. package/src/components/Card/Card.test.ts +32 -0
  23. package/src/components/Card/index.ts +2 -0
  24. package/src/components/Checkbox/Checkbox.svelte +62 -0
  25. package/src/components/Checkbox/Checkbox.test.ts +67 -0
  26. package/src/components/Checkbox/index.ts +2 -0
  27. package/src/components/CodeBlock/CodeBlock.svelte +14 -0
  28. package/src/components/CodeBlock/CodeBlock.test.ts +36 -0
  29. package/src/components/CodeBlock/index.ts +2 -0
  30. package/src/components/Container/Container.svelte +14 -0
  31. package/src/components/Container/Container.test.ts +23 -0
  32. package/src/components/Container/index.ts +2 -0
  33. package/src/components/DataReadout/DataReadout.svelte +19 -0
  34. package/src/components/DataReadout/DataReadout.test.ts +35 -0
  35. package/src/components/DataReadout/index.ts +2 -0
  36. package/src/components/Dialog/Dialog.svelte +131 -0
  37. package/src/components/Dialog/Dialog.test.ts +77 -0
  38. package/src/components/Dialog/index.ts +2 -0
  39. package/src/components/Divider/Divider.svelte +36 -0
  40. package/src/components/Divider/Divider.test.ts +34 -0
  41. package/src/components/Divider/index.ts +2 -0
  42. package/src/components/FormField/FormField.svelte +39 -0
  43. package/src/components/FormField/FormField.test.ts +58 -0
  44. package/src/components/FormField/index.ts +2 -0
  45. package/src/components/Grid/Grid.svelte +13 -0
  46. package/src/components/Grid/Grid.test.ts +32 -0
  47. package/src/components/Grid/index.ts +2 -0
  48. package/src/components/Input/Input.svelte +41 -0
  49. package/src/components/Input/Input.test.ts +64 -0
  50. package/src/components/Input/index.ts +2 -0
  51. package/src/components/Link/Link.svelte +17 -0
  52. package/src/components/Link/Link.test.ts +28 -0
  53. package/src/components/Link/index.ts +2 -0
  54. package/src/components/Nav/Nav.svelte +69 -0
  55. package/src/components/Nav/Nav.test.ts +75 -0
  56. package/src/components/Nav/index.ts +2 -0
  57. package/src/components/Progress/Progress.svelte +78 -0
  58. package/src/components/Progress/Progress.test.ts +58 -0
  59. package/src/components/Progress/index.ts +2 -0
  60. package/src/components/Radio/Radio.svelte +46 -0
  61. package/src/components/Radio/Radio.test.ts +52 -0
  62. package/src/components/Radio/index.ts +2 -0
  63. package/src/components/Section/Section.svelte +17 -0
  64. package/src/components/Section/Section.test.ts +29 -0
  65. package/src/components/Section/index.ts +2 -0
  66. package/src/components/Select/Select.svelte +45 -0
  67. package/src/components/Select/Select.test.ts +59 -0
  68. package/src/components/Select/index.ts +2 -0
  69. package/src/components/Skeleton/Skeleton.svelte +25 -0
  70. package/src/components/Skeleton/Skeleton.test.ts +44 -0
  71. package/src/components/Skeleton/index.ts +2 -0
  72. package/src/components/Slider/Slider.svelte +37 -0
  73. package/src/components/Slider/Slider.test.ts +45 -0
  74. package/src/components/Slider/index.ts +2 -0
  75. package/src/components/Spinner/Spinner.svelte +15 -0
  76. package/src/components/Spinner/Spinner.test.ts +38 -0
  77. package/src/components/Spinner/index.ts +2 -0
  78. package/src/components/Stack/Stack.svelte +27 -0
  79. package/src/components/Stack/Stack.test.ts +46 -0
  80. package/src/components/Stack/index.ts +2 -0
  81. package/src/components/Switch/Switch.svelte +48 -0
  82. package/src/components/Switch/Switch.test.ts +61 -0
  83. package/src/components/Switch/index.ts +2 -0
  84. package/src/components/Table/Table.svelte +67 -0
  85. package/src/components/Table/Table.test.ts +88 -0
  86. package/src/components/Table/index.ts +2 -0
  87. package/src/components/Tabs/Tabs.svelte +89 -0
  88. package/src/components/Tabs/Tabs.test.ts +66 -0
  89. package/src/components/Tabs/index.ts +2 -0
  90. package/src/components/Tag/Tag.svelte +33 -0
  91. package/src/components/Tag/Tag.test.ts +63 -0
  92. package/src/components/Tag/index.ts +2 -0
  93. package/src/components/Textarea/Textarea.svelte +53 -0
  94. package/src/components/Textarea/Textarea.test.ts +53 -0
  95. package/src/components/Textarea/index.ts +2 -0
  96. package/src/components/Toast/Toast.svelte +29 -0
  97. package/src/components/Toast/Toast.test.ts +60 -0
  98. package/src/components/Toast/ToastProvider.svelte +45 -0
  99. package/src/components/Toast/index.ts +5 -0
  100. package/src/components/Toast/useToast.ts +78 -0
  101. package/src/components/Tooltip/Tooltip.svelte +56 -0
  102. package/src/components/Tooltip/Tooltip.test.ts +50 -0
  103. package/src/components/Tooltip/index.ts +2 -0
  104. package/src/index.ts +46 -0
  105. package/src/test-setup.ts +7 -0
@@ -0,0 +1,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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Table } from './Table.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Tabs } from './Tabs.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Tag } from './Tag.svelte'
@@ -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,2 @@
1
+ /*! Strand Svelte | MIT License | dillingerstaffing.com */
2
+ export { default as Textarea } from './Textarea.svelte'
@@ -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
+ &#215;
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
+ &#215;
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'