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