@txstate-mws/svelte-components 1.0.4 → 1.1.0
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/.eslintrc.cjs +37 -0
- package/docs/CollapsingTable.md +79 -0
- package/docs/ConditionalWrapper.md +35 -0
- package/docs/Modal.md +63 -0
- package/docs/PopupMenu.md +31 -0
- package/docs/ScreenReaderOnly.md +10 -0
- package/docs/actions.md +84 -0
- package/docs/stores.md +203 -0
- package/package.json +15 -37
- package/playwright.config.js +112 -0
- package/src/app.html +14 -0
- package/src/global.d.ts +1 -0
- package/src/lib/actions/buttonify.ts +27 -0
- package/src/lib/actions/glue.ts +96 -0
- package/src/lib/actions/index.ts +7 -0
- package/src/lib/actions/offset.ts +43 -0
- package/src/lib/actions/portal.ts +13 -0
- package/src/lib/actions/resize.ts +72 -0
- package/src/lib/actions/sticky.ts +105 -0
- package/src/lib/actions/stickyfixed.ts +160 -0
- package/src/lib/components/Card.svelte +25 -0
- package/src/lib/components/CardLayout.svelte +188 -0
- package/{components → src/lib/components}/CollapsingTable.svelte +65 -63
- package/{components → src/lib/components}/ConditionalWrapper.svelte +7 -6
- package/src/lib/components/FocusLock.svelte +102 -0
- package/{components → src/lib/components}/Modal.svelte +24 -22
- package/src/lib/components/MultiSelect.svelte +174 -0
- package/src/lib/components/PopupMenu.svelte +285 -0
- package/{components → src/lib/components}/ScreenReaderOnly.svelte +4 -3
- package/src/lib/components/index.ts +9 -0
- package/src/lib/index.ts +4 -0
- package/src/lib/types/CardLayout.ts +14 -0
- package/src/lib/types/CollapsingTable.ts +8 -0
- package/src/lib/types/PopupMenu.ts +5 -0
- package/src/lib/types/index.ts +3 -0
- package/src/lib/util/bodyoffset.ts +77 -0
- package/src/lib/util/index.ts +3 -0
- package/src/lib/util/repositioned.ts +91 -0
- package/src/lib/util/util.ts +36 -0
- package/src/routes/__layout.svelte +125 -0
- package/src/routes/actions/index.svelte +1 -0
- package/src/routes/actions/offset.svelte +4 -0
- package/src/routes/actions/offsetdebounce.svelte +4 -0
- package/src/routes/actions/resize.svelte +4 -0
- package/src/routes/actions/resizedebounce.svelte +4 -0
- package/src/routes/actions/sticky.svelte +4 -0
- package/src/routes/cards/index.svelte +4 -0
- package/src/routes/cards/preserve.svelte +4 -0
- package/src/routes/collapsingtable/index.svelte +4 -0
- package/src/routes/collapsingtable/stickyheader.svelte +4 -0
- package/src/routes/index.svelte +2 -0
- package/src/routes/modal.svelte +4 -0
- package/src/routes/multiselect.svelte +4 -0
- package/src/routes/popupmenu/body.svelte +4 -0
- package/src/routes/popupmenu/complex.svelte +4 -0
- package/src/routes/popupmenu/empty.svelte +5 -0
- package/src/routes/popupmenu/index.svelte +4 -0
- package/src/routes/popupmenu/parent.svelte +4 -0
- package/src/routes/popupmenu/scrollable.svelte +27 -0
- package/src/routes/popupmenu/showselected.svelte +4 -0
- package/src/test/Cards.svelte +64 -0
- package/src/test/CollapsingTableTest.svelte +36 -0
- package/src/test/ModalTest.svelte +58 -0
- package/src/test/MultiSelectTest.svelte +26 -0
- package/src/test/Offset.svelte +32 -0
- package/src/test/PopupMenuBody.svelte +34 -0
- package/src/test/PopupMenuButtonIsParent.svelte +36 -0
- package/src/test/PopupMenuComplex.svelte +34 -0
- package/src/test/PopupMenuSimple.svelte +36 -0
- package/src/test/Resize.svelte +29 -0
- package/src/test/Sticky.svelte +65 -0
- package/static/layout.css +263 -0
- package/svelte.config.js +15 -0
- package/tests/.eslintrc.cjs +19 -0
- package/tests/CardLayout.test.js +41 -0
- package/tests/CollapsingTable.test.js +37 -0
- package/tests/Modal.test.js +18 -0
- package/tests/MultiSelect.test.js +58 -0
- package/tests/PopupMenu.test.js +55 -0
- package/tests/common.js +4 -0
- package/tsconfig.json +31 -0
- package/actions/buttonify.d.ts +0 -8
- package/actions/buttonify.js +0 -26
- package/actions/glue.d.ts +0 -16
- package/actions/glue.js +0 -82
- package/actions/index.d.ts +0 -7
- package/actions/index.js +0 -7
- package/actions/offset.d.ts +0 -15
- package/actions/offset.js +0 -35
- package/actions/portal.d.ts +0 -4
- package/actions/portal.js +0 -13
- package/actions/resize.d.ts +0 -20
- package/actions/resize.js +0 -45
- package/actions/sticky.d.ts +0 -33
- package/actions/sticky.js +0 -85
- package/actions/stickyfixed.d.ts +0 -25
- package/actions/stickyfixed.js +0 -157
- package/components/CollapsingTable.svelte.d.ts +0 -59
- package/components/ConditionalWrapper.svelte.d.ts +0 -23
- package/components/FocusLock.svelte +0 -107
- package/components/FocusLock.svelte.d.ts +0 -26
- package/components/Modal.svelte.d.ts +0 -27
- package/components/PopupMenu.svelte +0 -279
- package/components/PopupMenu.svelte.d.ts +0 -44
- package/components/ScreenReaderOnly.svelte.d.ts +0 -20
- package/components/index.d.ts +0 -6
- package/components/index.js +0 -6
- package/index.d.ts +0 -4
- package/index.js +0 -4
- package/types/CollapsingTable.d.ts +0 -8
- package/types/CollapsingTable.js +0 -1
- package/types/PopupMenu.d.ts +0 -5
- package/types/PopupMenu.js +0 -1
- package/types/index.d.ts +0 -2
- package/types/index.js +0 -2
- package/util/bodyoffset.d.ts +0 -14
- package/util/bodyoffset.js +0 -72
- package/util/index.d.ts +0 -3
- package/util/index.js +0 -3
- package/util/repositioned.d.ts +0 -12
- package/util/repositioned.js +0 -81
- package/util/util.d.ts +0 -2
- package/util/util.js +0 -10
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* See https://playwright.dev/docs/test-configuration.
|
|
5
|
+
*/
|
|
6
|
+
export const config = {
|
|
7
|
+
|
|
8
|
+
testDir: './tests',
|
|
9
|
+
|
|
10
|
+
/* Maximum time one test can run for. */
|
|
11
|
+
timeout: 30 * 1000,
|
|
12
|
+
|
|
13
|
+
expect: {
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maximum time expect() should wait for the condition to be met.
|
|
17
|
+
* For example in `await expect(locator).toHaveText();`
|
|
18
|
+
*/
|
|
19
|
+
timeout: 5000
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
23
|
+
forbidOnly: !!process.env.CI,
|
|
24
|
+
|
|
25
|
+
/* Retry on CI only */
|
|
26
|
+
retries: process.env.CI ? 2 : 0,
|
|
27
|
+
|
|
28
|
+
/* Opt out of parallel tests on CI. */
|
|
29
|
+
workers: process.env.CI ? 1 : undefined,
|
|
30
|
+
|
|
31
|
+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
32
|
+
reporter: 'line',
|
|
33
|
+
|
|
34
|
+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
35
|
+
use: {
|
|
36
|
+
|
|
37
|
+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
|
38
|
+
actionTimeout: 0,
|
|
39
|
+
|
|
40
|
+
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
41
|
+
// baseURL: 'http://localhost:3000',
|
|
42
|
+
|
|
43
|
+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
44
|
+
trace: 'on-first-retry',
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/* Configure projects for major browsers */
|
|
48
|
+
projects: [
|
|
49
|
+
{
|
|
50
|
+
name: 'chromium',
|
|
51
|
+
|
|
52
|
+
/* Project-specific settings. */
|
|
53
|
+
use: {
|
|
54
|
+
...devices['Desktop Chrome'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: 'firefox',
|
|
60
|
+
use: {
|
|
61
|
+
...devices['Desktop Firefox'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
name: 'webkit',
|
|
67
|
+
use: {
|
|
68
|
+
...devices['Desktop Safari'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/* Test against mobile viewports. */
|
|
73
|
+
// {
|
|
74
|
+
// name: 'Mobile Chrome',
|
|
75
|
+
// use: {
|
|
76
|
+
// ...devices['Pixel 5'],
|
|
77
|
+
// },
|
|
78
|
+
// },
|
|
79
|
+
// {
|
|
80
|
+
// name: 'Mobile Safari',
|
|
81
|
+
// use: {
|
|
82
|
+
// ...devices['iPhone 12'],
|
|
83
|
+
// },
|
|
84
|
+
// },
|
|
85
|
+
|
|
86
|
+
/* Test against branded browsers. */
|
|
87
|
+
// {
|
|
88
|
+
// name: 'Microsoft Edge',
|
|
89
|
+
// use: {
|
|
90
|
+
// channel: 'msedge',
|
|
91
|
+
// },
|
|
92
|
+
// },
|
|
93
|
+
// {
|
|
94
|
+
// name: 'Google Chrome',
|
|
95
|
+
// use: {
|
|
96
|
+
// channel: 'chrome',
|
|
97
|
+
// },
|
|
98
|
+
// },
|
|
99
|
+
],
|
|
100
|
+
|
|
101
|
+
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
|
102
|
+
// outputDir: 'test-results/',
|
|
103
|
+
|
|
104
|
+
/* Run your local dev server before starting the tests */
|
|
105
|
+
webServer: {
|
|
106
|
+
command: 'npm run build && npm run preview',
|
|
107
|
+
port: 3000,
|
|
108
|
+
timeout: 120 * 1000,
|
|
109
|
+
reuseExistingServer: !process.env.CI
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
export default config;
|
package/src/app.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="description" content="" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<link rel="stylesheet" href="https://unpkg.com/purecss@2.0.6/build/pure-min.css" integrity="sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5" crossorigin="anonymous">
|
|
8
|
+
<link rel="stylesheet" href="/layout.css" />
|
|
9
|
+
%svelte.head%
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="svelte">%svelte.body%</div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="@sveltejs/kit" />
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns any HTML element into an accessible button that responds to keyboard events and has
|
|
3
|
+
* role="button" and the tabindex attribute set. You are still responsible for making sure it has
|
|
4
|
+
* readable content or a good label.
|
|
5
|
+
*/
|
|
6
|
+
export function buttonify (node: HTMLElement) {
|
|
7
|
+
function keydown (e: KeyboardEvent) {
|
|
8
|
+
if (e.key === 'Enter') {
|
|
9
|
+
if (!e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
10
|
+
e.preventDefault()
|
|
11
|
+
e.stopPropagation()
|
|
12
|
+
node.dispatchEvent(new CustomEvent('click'))
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
node.addEventListener('keydown', keydown)
|
|
18
|
+
if (node.tabIndex < 0) node.tabIndex = 0
|
|
19
|
+
node.setAttribute('role', 'button')
|
|
20
|
+
node.style.cursor = 'pointer'
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
destroy () {
|
|
24
|
+
node.removeEventListener('keydown', keydown)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SettableSubject } from '@txstate-mws/svelte-store'
|
|
2
|
+
import { sharedOffsetParent, targetOffset, watchForPositionChangeInContainer } from '../util/index.js'
|
|
3
|
+
import type { ElementOffsets } from '../util/index.js'
|
|
4
|
+
export type GlueAlignOpts = 'auto'|'bottomleft'|'bottomright'|'topleft'|'topright'
|
|
5
|
+
|
|
6
|
+
export interface GlueArgs<T extends GlueAlignStore = GlueAlignStore> {
|
|
7
|
+
target: HTMLElement
|
|
8
|
+
align?: GlueAlignOpts
|
|
9
|
+
cover?: boolean
|
|
10
|
+
store?: SettableSubject<T>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GlueAlignStore {
|
|
14
|
+
valign: 'bottom'|'top'
|
|
15
|
+
halign: 'left'|'right'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let setBodyRelative = false
|
|
19
|
+
export function glue (el: HTMLElement, { target, align = 'auto', cover = false, store }: GlueArgs) {
|
|
20
|
+
if (!setBodyRelative) {
|
|
21
|
+
setBodyRelative = true
|
|
22
|
+
document.body.style.position = 'relative'
|
|
23
|
+
}
|
|
24
|
+
let halign: GlueAlignStore['halign']
|
|
25
|
+
let valign: GlueAlignStore['valign']
|
|
26
|
+
let sharedOffset = sharedOffsetParent(el, target)
|
|
27
|
+
|
|
28
|
+
function reposition (offset: Required<ElementOffsets>) {
|
|
29
|
+
if (!target || !sharedOffset || !(el.offsetParent instanceof HTMLElement)) return
|
|
30
|
+
let autoalign = align
|
|
31
|
+
const parentoffset = el.offsetParent === sharedOffset && target !== sharedOffset ? { top: 0, left: 0, bottom: 0, right: 0 } : targetOffset(el.offsetParent, sharedOffset)
|
|
32
|
+
if (align === 'auto') {
|
|
33
|
+
const rect = target.getBoundingClientRect()
|
|
34
|
+
const leftright = window.innerWidth - rect.right > rect.left ? 'left' : 'right'
|
|
35
|
+
const topbottom = window.innerHeight - rect.bottom > rect.top ? 'bottom' : 'top'
|
|
36
|
+
autoalign = topbottom + leftright as 'auto'
|
|
37
|
+
}
|
|
38
|
+
const targetHeight = target.offsetHeight
|
|
39
|
+
if (autoalign === 'bottomleft') {
|
|
40
|
+
requestAnimationFrame(() => {
|
|
41
|
+
el.style.top = `${offset.top + (cover ? 0 : targetHeight) - parentoffset.top}px`
|
|
42
|
+
el.style.left = `${offset.left - parentoffset.left}px`
|
|
43
|
+
el.style.bottom = ''
|
|
44
|
+
el.style.right = ''
|
|
45
|
+
})
|
|
46
|
+
valign = 'bottom'
|
|
47
|
+
halign = 'left'
|
|
48
|
+
} else if (autoalign === 'bottomright') {
|
|
49
|
+
requestAnimationFrame(() => {
|
|
50
|
+
el.style.top = `${offset.top + (cover ? 0 : targetHeight) - parentoffset.top}px`
|
|
51
|
+
el.style.left = ''
|
|
52
|
+
el.style.bottom = ''
|
|
53
|
+
el.style.right = `${offset.right - parentoffset.right}px`
|
|
54
|
+
})
|
|
55
|
+
valign = 'bottom'
|
|
56
|
+
halign = 'right'
|
|
57
|
+
} else if (autoalign === 'topleft') {
|
|
58
|
+
requestAnimationFrame(() => {
|
|
59
|
+
el.style.top = ''
|
|
60
|
+
el.style.left = `${offset.left - parentoffset.left}px`
|
|
61
|
+
el.style.bottom = `${offset.bottom + (cover ? 0 : targetHeight) - parentoffset.bottom}px`
|
|
62
|
+
el.style.right = ''
|
|
63
|
+
})
|
|
64
|
+
valign = 'top'
|
|
65
|
+
halign = 'left'
|
|
66
|
+
} else if (autoalign === 'topright') {
|
|
67
|
+
requestAnimationFrame(() => {
|
|
68
|
+
el.style.top = ''
|
|
69
|
+
el.style.left = ''
|
|
70
|
+
el.style.bottom = `${offset.bottom + (cover ? 0 : targetHeight) - parentoffset.bottom}px`
|
|
71
|
+
el.style.right = `${offset.right - parentoffset.right}px`
|
|
72
|
+
})
|
|
73
|
+
valign = 'top'
|
|
74
|
+
halign = 'right'
|
|
75
|
+
}
|
|
76
|
+
store?.update(v => ({ ...v, valign, halign }))
|
|
77
|
+
}
|
|
78
|
+
const { destroy, update } = watchForPositionChangeInContainer(target, sharedOffset, reposition)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
update ({ target: utarget, align: ualign = 'auto', cover: ucover = false, store: ustore }: GlueArgs) {
|
|
82
|
+
align = ualign
|
|
83
|
+
cover = ucover
|
|
84
|
+
if (ustore && ustore !== store) {
|
|
85
|
+
ustore.update(v => ({ ...v, valign, halign }))
|
|
86
|
+
store = ustore
|
|
87
|
+
}
|
|
88
|
+
if (target !== utarget) {
|
|
89
|
+
sharedOffset = sharedOffsetParent(el, utarget)
|
|
90
|
+
target = utarget
|
|
91
|
+
update(target, sharedOffset, reposition)
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
destroy
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Store } from '@txstate-mws/svelte-store'
|
|
2
|
+
import type { WritableSubject } from '@txstate-mws/svelte-store'
|
|
3
|
+
import { debounced, watchForPositionChange } from '../util/index.js'
|
|
4
|
+
import type { ElementOffsets } from '../util/index.js'
|
|
5
|
+
|
|
6
|
+
interface OffsetConfig<T extends ElementOffsets = ElementOffsets> {
|
|
7
|
+
debounce?: boolean|number
|
|
8
|
+
store?: WritableSubject<T>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class OffsetStore extends Store<ElementOffsets> {
|
|
12
|
+
constructor (initialState?: ElementOffsets) {
|
|
13
|
+
super(initialState ?? { top: 0, right: 0, bottom: 0, left: 0 })
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function offset (el: HTMLElement, config?: OffsetConfig) {
|
|
18
|
+
if (config?.debounce === true) config.debounce = 100
|
|
19
|
+
let lastOffset: ElementOffsets = {}
|
|
20
|
+
function lookforoffsetchange (current: ElementOffsets) {
|
|
21
|
+
lastOffset = current
|
|
22
|
+
if (config?.store) config.store.update(v => ({ ...v, ...current }))
|
|
23
|
+
el.dispatchEvent(new CustomEvent('offset', { detail: current }))
|
|
24
|
+
}
|
|
25
|
+
let resolvedchange = config?.debounce ? debounced(lookforoffsetchange, config.debounce) : lookforoffsetchange
|
|
26
|
+
|
|
27
|
+
const { destroy, update } = watchForPositionChange(el, resolvedchange)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
update (newConfig?: OffsetConfig) {
|
|
31
|
+
if (newConfig?.debounce === true) newConfig.debounce = 100
|
|
32
|
+
if (newConfig?.store !== config?.store) {
|
|
33
|
+
newConfig?.store?.update(v => ({ ...v, ...lastOffset }))
|
|
34
|
+
}
|
|
35
|
+
if (newConfig?.debounce !== config?.debounce) {
|
|
36
|
+
resolvedchange = newConfig?.debounce ? debounced(lookforoffsetchange, newConfig.debounce as any) : lookforoffsetchange
|
|
37
|
+
update(el, resolvedchange)
|
|
38
|
+
}
|
|
39
|
+
config = newConfig
|
|
40
|
+
},
|
|
41
|
+
destroy
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function portal (el: HTMLElement, target: HTMLElement|null = document.body) {
|
|
2
|
+
function update (target: HTMLElement|null = document.body) {
|
|
3
|
+
target?.appendChild(el)
|
|
4
|
+
}
|
|
5
|
+
function destroy () {
|
|
6
|
+
el.parentElement?.removeChild(el)
|
|
7
|
+
}
|
|
8
|
+
update(target)
|
|
9
|
+
return {
|
|
10
|
+
update,
|
|
11
|
+
destroy
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { equal } from 'txstate-utils'
|
|
2
|
+
import { Store } from '@txstate-mws/svelte-store'
|
|
3
|
+
import type { SettableSubject } from '@txstate-mws/svelte-store'
|
|
4
|
+
import { debounced } from '../util/index.js'
|
|
5
|
+
|
|
6
|
+
export interface ElementSize {
|
|
7
|
+
clientWidth?: number
|
|
8
|
+
clientHeight?: number
|
|
9
|
+
offsetWidth?: number
|
|
10
|
+
offsetHeight?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ResizeConfig {
|
|
14
|
+
debounce?: boolean|number
|
|
15
|
+
store?: SettableSubject<ElementSize>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ResizeStore extends Store<ElementSize> {
|
|
19
|
+
constructor (initialState?: ElementSize) {
|
|
20
|
+
super(initialState ?? typeof window === 'undefined'
|
|
21
|
+
? {
|
|
22
|
+
clientWidth: 1024,
|
|
23
|
+
clientHeight: 1024,
|
|
24
|
+
offsetWidth: 1024,
|
|
25
|
+
offsetHeight: 1024
|
|
26
|
+
}
|
|
27
|
+
: {
|
|
28
|
+
clientWidth: window.innerWidth,
|
|
29
|
+
clientHeight: window.innerHeight,
|
|
30
|
+
offsetWidth: window.innerWidth,
|
|
31
|
+
offsetHeight: window.innerHeight
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resize (el: HTMLElement, config?: ResizeConfig) {
|
|
38
|
+
if (config?.debounce === true) config.debounce = 100
|
|
39
|
+
let lastSize: ElementSize = {}
|
|
40
|
+
function watch () {
|
|
41
|
+
const current = { clientWidth: el.clientWidth, clientHeight: el.clientHeight, offsetWidth: el.offsetWidth, offsetHeight: el.offsetHeight }
|
|
42
|
+
if (!equal(lastSize, current)) {
|
|
43
|
+
lastSize = current
|
|
44
|
+
if (config?.store) config.store.set(current)
|
|
45
|
+
el.dispatchEvent(new CustomEvent('resize', { detail: current }))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof ResizeObserver === 'undefined') return watch() // support SSR
|
|
50
|
+
|
|
51
|
+
let observer = new ResizeObserver(config?.debounce ? debounced(watch, config.debounce) : watch)
|
|
52
|
+
observer.observe(el)
|
|
53
|
+
watch()
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
update (newConfig?: ResizeConfig) {
|
|
57
|
+
if (newConfig?.debounce === true) newConfig.debounce = 100
|
|
58
|
+
if (newConfig?.store !== config?.store) {
|
|
59
|
+
newConfig?.store?.set(lastSize)
|
|
60
|
+
}
|
|
61
|
+
if (!!newConfig?.debounce !== !!config?.debounce) {
|
|
62
|
+
observer.disconnect()
|
|
63
|
+
observer = new ResizeObserver(newConfig?.debounce ? debounced(watch, newConfig.debounce) : watch)
|
|
64
|
+
observer.observe(el)
|
|
65
|
+
}
|
|
66
|
+
config = newConfig
|
|
67
|
+
},
|
|
68
|
+
destroy () {
|
|
69
|
+
observer.disconnect()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { SettableSubject } from '@txstate-mws/svelte-store'
|
|
2
|
+
import { bodyOffset, watchForPositionChange } from '../util/index.js'
|
|
3
|
+
import type { ElementOffsets } from '../util/index.js'
|
|
4
|
+
|
|
5
|
+
export interface StickyArgs <T extends StickyStore = StickyStore> {
|
|
6
|
+
target?: HTMLElement
|
|
7
|
+
store?: SettableSubject<T>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StickyStore {
|
|
11
|
+
floating?: boolean
|
|
12
|
+
translateY?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let scrolled = false
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A robust implementation of stickyness based on `transform: translateY()`. Only use when
|
|
19
|
+
* `position: sticky` won't work for you. If `position: sticky` does work for you, use it,
|
|
20
|
+
* it performs better.
|
|
21
|
+
*
|
|
22
|
+
* Reasons `position: sticky` might not work:
|
|
23
|
+
* 1) it cannot work on a thead or tr, only td and th (and non-table elements)
|
|
24
|
+
* 2) it gets confused if any parent elements have an `overflow` set, even `overflow: hidden`
|
|
25
|
+
* 3) it always stays within the boundaries of the closest parent element that has a `position`
|
|
26
|
+
* * use:sticky accepts a `target` element as a parameter, defining the bounding container
|
|
27
|
+
*
|
|
28
|
+
* Note that if the element you place this on has other transforms, this is going to overwrite them.
|
|
29
|
+
*
|
|
30
|
+
* The main drawback to this approach is that it cannot do its work before scroll on mobile, only after,
|
|
31
|
+
* so on mobile you are going to see it visually lag a little during scrolling.
|
|
32
|
+
*
|
|
33
|
+
* You may also want to try use:stickyfixed. It is based on setting `position: fixed` when appropriate,
|
|
34
|
+
* and therefore animates better than this. However, it has a lot more work to do and is more
|
|
35
|
+
* fragile if your markup/CSS situation is very complex.
|
|
36
|
+
*/
|
|
37
|
+
export function sticky (el: HTMLElement, config?: StickyArgs) {
|
|
38
|
+
let floating: boolean
|
|
39
|
+
let translateY: number
|
|
40
|
+
let scrollstart = Number.MAX_SAFE_INTEGER
|
|
41
|
+
let scrollend = Number.MAX_SAFE_INTEGER
|
|
42
|
+
|
|
43
|
+
function recalc (scroll: number) {
|
|
44
|
+
const newTranslateY = Math.max(Math.min(scroll, scrollend) - scrollstart, 0)
|
|
45
|
+
if (newTranslateY !== translateY) {
|
|
46
|
+
el.style.transform = `translateY(${newTranslateY}px)`
|
|
47
|
+
translateY = newTranslateY
|
|
48
|
+
floating = scroll >= scrollstart
|
|
49
|
+
config?.store?.update(v => ({ ...v, translateY, floating }))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onscroll (_: Event) {
|
|
54
|
+
recalc(window.scrollY)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onwheel (e: WheelEvent) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
const scroll = window.scrollY + e.deltaY
|
|
60
|
+
recalc(scroll)
|
|
61
|
+
if (!scrolled) {
|
|
62
|
+
window.scrollTo({ top: scroll })
|
|
63
|
+
scrolled = true
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
scrolled = false
|
|
66
|
+
}, 0)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let lastOffsetParent: HTMLElement = el.offsetParent as HTMLElement
|
|
71
|
+
function onpositionchange (offset: Required<ElementOffsets>[]) {
|
|
72
|
+
const offsetParent = el.offsetParent
|
|
73
|
+
if (!(offsetParent instanceof HTMLElement)) return
|
|
74
|
+
if (lastOffsetParent !== offsetParent) {
|
|
75
|
+
offset[0] = bodyOffset(offsetParent)
|
|
76
|
+
update(offsetParent, onpositionchange)
|
|
77
|
+
lastOffsetParent = offsetParent
|
|
78
|
+
}
|
|
79
|
+
scrollstart = offset[0].top + el.offsetTop
|
|
80
|
+
const containeroffset = config?.target ? offset[1] : offset[0]
|
|
81
|
+
scrollend = Math.max(scrollstart, containeroffset.top + (config?.target ?? offsetParent).clientHeight - el.offsetHeight)
|
|
82
|
+
recalc(window.scrollY)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { destroy, update } = watchForPositionChange([el.offsetParent as HTMLElement, config?.target], onpositionchange)
|
|
86
|
+
window.addEventListener('scroll', onscroll, { passive: true })
|
|
87
|
+
window.addEventListener('wheel', onwheel, { passive: false })
|
|
88
|
+
|
|
89
|
+
function stickyUpdate (newConfig?: StickyArgs) {
|
|
90
|
+
if (newConfig?.store !== config?.store) newConfig?.store?.update(v => ({ ...v, translateY, floating }))
|
|
91
|
+
config = newConfig
|
|
92
|
+
update([el.offsetParent as HTMLElement, config?.target], onpositionchange)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stickyDestroy () {
|
|
96
|
+
window.removeEventListener('scroll', onscroll)
|
|
97
|
+
window.removeEventListener('wheel', onwheel)
|
|
98
|
+
destroy()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
update: stickyUpdate,
|
|
103
|
+
destroy: stickyDestroy
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { bodyOffset, watchForPositionChange } from '../util/index.js'
|
|
2
|
+
import type { ElementOffsets } from '../util/index.js'
|
|
3
|
+
import type { StickyArgs } from './sticky.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An implementation of stickyness based on `positon: fixed`. Only use when
|
|
7
|
+
* `position: sticky` won't work for you. If `position: sticky` does work for you, use it,
|
|
8
|
+
* it performs better.
|
|
9
|
+
*
|
|
10
|
+
* Reasons `position: sticky` might not work:
|
|
11
|
+
* 1) it cannot work on a thead or tr, only td and th (and non-table elements)
|
|
12
|
+
* 2) it gets confused if any parent elements have an `overflow` set, even `overflow: hidden`
|
|
13
|
+
* 3) it always stays within the boundaries of the closest parent element that has a `position`
|
|
14
|
+
* * use:sticky accepts a `target` element as a parameter, defining the bounding container
|
|
15
|
+
*
|
|
16
|
+
* Note that this implementation gets great animation performance at the cost of extra complexity.
|
|
17
|
+
* It does a lot of work like cloning the element you place it on, placing the cloned element next
|
|
18
|
+
* to yours in the DOM, setting widths to match between them, watching for mutations to keep them
|
|
19
|
+
* in sync, etc. Some of this work may get foiled if you have a complex html/css/js situation.
|
|
20
|
+
*
|
|
21
|
+
* You may also want to try use:sticky. It is based on setting `transform: translateY()` as appropriate,
|
|
22
|
+
* and is less fragile than this. However, it cannot do its work before the scroll on mobile, only after,
|
|
23
|
+
* so it will visually lag a bit on mobile.
|
|
24
|
+
*/
|
|
25
|
+
export function stickyfixed (el: HTMLElement, config?: StickyArgs) {
|
|
26
|
+
let floating: boolean
|
|
27
|
+
let translateY: number
|
|
28
|
+
let scrollstart = Number.MAX_SAFE_INTEGER
|
|
29
|
+
let scrollend = Number.MAX_SAFE_INTEGER
|
|
30
|
+
let spacer: HTMLElement
|
|
31
|
+
let elheight: number
|
|
32
|
+
let left: number
|
|
33
|
+
let top = 0
|
|
34
|
+
|
|
35
|
+
function generatespacer (mutations?: MutationRecord[]) {
|
|
36
|
+
if (mutations?.every(m => m.attributeName === 'style')) return
|
|
37
|
+
spacer?.remove()
|
|
38
|
+
spacer = el.cloneNode(true) as HTMLElement
|
|
39
|
+
spacer.setAttribute('aria-hidden', 'true')
|
|
40
|
+
spacer.style.position = ''
|
|
41
|
+
spacer.style.top = ''
|
|
42
|
+
spacer.style.left = ''
|
|
43
|
+
spacer.style.width = ''
|
|
44
|
+
const tds = Array.from(spacer.querySelectorAll<HTMLElement>('td,th'))
|
|
45
|
+
for (const td of tds) td.style.width = ''
|
|
46
|
+
if (floating) el.parentElement?.insertBefore(spacer, el)
|
|
47
|
+
}
|
|
48
|
+
const mutationobserver = new MutationObserver(generatespacer)
|
|
49
|
+
mutationobserver.observe(el, {
|
|
50
|
+
subtree: true,
|
|
51
|
+
childList: true,
|
|
52
|
+
attributes: true,
|
|
53
|
+
characterData: true
|
|
54
|
+
})
|
|
55
|
+
generatespacer()
|
|
56
|
+
|
|
57
|
+
function adjustwidths () {
|
|
58
|
+
const tds = Array.from(el.querySelectorAll<HTMLElement>('td,th'))
|
|
59
|
+
if (floating) {
|
|
60
|
+
const spacertds = Array.from(spacer.querySelectorAll<HTMLElement>('td,th'))
|
|
61
|
+
const widths = spacertds.map(td => window.getComputedStyle(td).width)
|
|
62
|
+
const table = spacer.closest('table')
|
|
63
|
+
let extrawidth = 0
|
|
64
|
+
if (table) {
|
|
65
|
+
const cell = el.querySelector('th:last-child,td:last-child')
|
|
66
|
+
if (cell) {
|
|
67
|
+
const tstyle = window.getComputedStyle(table)
|
|
68
|
+
extrawidth = tstyle.borderCollapse === 'collapse' ? parseInt(window.getComputedStyle(cell).borderRightWidth) : 0
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const width = `${parseInt(window.getComputedStyle(spacer).width) + extrawidth}px`
|
|
72
|
+
elheight = spacer.offsetHeight
|
|
73
|
+
for (let i = 0; i < tds.length; i++) tds[i].style.width = widths[i]
|
|
74
|
+
el.style.width = width
|
|
75
|
+
} else {
|
|
76
|
+
for (const td of tds) td.style.width = ''
|
|
77
|
+
el.style.width = ''
|
|
78
|
+
elheight = el.offsetHeight
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resizeobserver = new ResizeObserver(adjustwidths)
|
|
83
|
+
|
|
84
|
+
function recalc (scroll: number) {
|
|
85
|
+
const newTranslateY = Math.max(Math.min(scroll - 1, scrollend) - scrollstart, 0)
|
|
86
|
+
const newFloating = newTranslateY > 0
|
|
87
|
+
const newTop = Math.max(Math.min(scrollend - scroll, 0), -2 * elheight)
|
|
88
|
+
if (newFloating !== floating) {
|
|
89
|
+
floating = newFloating
|
|
90
|
+
if (floating) {
|
|
91
|
+
el.parentElement?.insertBefore(spacer, el)
|
|
92
|
+
el.style.position = 'fixed'
|
|
93
|
+
el.style.top = `${Math.min(scrollend - scroll, 0)}px`
|
|
94
|
+
el.style.left = `${left}px`
|
|
95
|
+
resizeobserver.observe(spacer)
|
|
96
|
+
} else {
|
|
97
|
+
el.style.position = ''
|
|
98
|
+
el.style.top = ''
|
|
99
|
+
el.style.left = ''
|
|
100
|
+
resizeobserver.disconnect()
|
|
101
|
+
spacer.remove()
|
|
102
|
+
}
|
|
103
|
+
adjustwidths()
|
|
104
|
+
}
|
|
105
|
+
if (newTranslateY !== translateY) {
|
|
106
|
+
translateY = newTranslateY
|
|
107
|
+
config?.store?.update(v => ({ ...v, translateY, floating }))
|
|
108
|
+
}
|
|
109
|
+
if (top !== newTop) {
|
|
110
|
+
top = newTop
|
|
111
|
+
if (floating) el.style.top = `${top}px`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onscroll (_: Event) {
|
|
116
|
+
recalc(window.scrollY)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let lastOffsetParent: HTMLElement = el.offsetParent as HTMLElement
|
|
120
|
+
function onpositionchange (offset: Required<ElementOffsets>[]) {
|
|
121
|
+
const offsetParent = el.offsetParent
|
|
122
|
+
if (!(offsetParent instanceof HTMLElement)) return
|
|
123
|
+
if (lastOffsetParent !== offsetParent) {
|
|
124
|
+
offset[0] = bodyOffset(offsetParent)
|
|
125
|
+
update(offsetParent, onpositionchange)
|
|
126
|
+
lastOffsetParent = offsetParent
|
|
127
|
+
}
|
|
128
|
+
const table = el.closest('table')
|
|
129
|
+
const borderspacing = (table ? parseInt(window.getComputedStyle(table).borderSpacing) : 0)
|
|
130
|
+
scrollstart = offset[0].top + el.offsetTop
|
|
131
|
+
left = offset[0].left + (spacer.parentElement ? spacer.offsetLeft : el.offsetLeft)
|
|
132
|
+
if (el.tagName === 'TR') {
|
|
133
|
+
scrollstart -= borderspacing
|
|
134
|
+
}
|
|
135
|
+
const containeroffset = config?.target ? offset[1] ?? offset[0] : offset[0]
|
|
136
|
+
scrollend = Math.max(scrollstart, containeroffset.top + (config?.target ?? offsetParent).clientHeight - el.offsetHeight)
|
|
137
|
+
recalc(window.scrollY)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { destroy, update } = watchForPositionChange([el.offsetParent as HTMLElement, config?.target], onpositionchange)
|
|
141
|
+
window.addEventListener('scroll', onscroll, { passive: true })
|
|
142
|
+
|
|
143
|
+
function stickyUpdate (newConfig?: StickyArgs) {
|
|
144
|
+
if (newConfig?.store !== config?.store) newConfig?.store?.update(v => ({ ...v, translateY, floating }))
|
|
145
|
+
config = newConfig
|
|
146
|
+
update([el.offsetParent as HTMLElement, config?.target], onpositionchange)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stickyDestroy () {
|
|
150
|
+
resizeobserver.disconnect()
|
|
151
|
+
mutationobserver.disconnect()
|
|
152
|
+
window.removeEventListener('scroll', onscroll)
|
|
153
|
+
destroy()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
update: stickyUpdate,
|
|
158
|
+
destroy: stickyDestroy
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export let className = ''
|
|
3
|
+
import { getContext } from 'svelte'
|
|
4
|
+
import { resize } from '$lib/actions'
|
|
5
|
+
import { CARDLAYOUT } from '$lib/types'
|
|
6
|
+
import type { CardLayoutContext, Block } from '$lib/types'
|
|
7
|
+
const block: Block = { element: undefined }
|
|
8
|
+
const { registerBlock, gutter, recalculate } = getContext<CardLayoutContext>(CARDLAYOUT)
|
|
9
|
+
const { width, order, linebreak } = registerBlock(block)
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<li class="cardlayout-card {className}" bind:this={block.element} use:resize on:resize={recalculate} style="margin-bottom: {$gutter}px; width: {$width}; order: {$order};">
|
|
13
|
+
<slot></slot>
|
|
14
|
+
</li>
|
|
15
|
+
{#if $linebreak}
|
|
16
|
+
<li aria-hidden="true" class="cardlayout-columnbreak" style:order={$order}></li>
|
|
17
|
+
{/if}
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.cardlayout-columnbreak {
|
|
21
|
+
width: 0;
|
|
22
|
+
height: 100%;
|
|
23
|
+
visibility: hidden;
|
|
24
|
+
}
|
|
25
|
+
</style>
|