@operato/popup 10.11.0 → 10.15.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/coverage/lcov-report/base.css +224 -0
  3. package/coverage/lcov-report/block-navigation.js +87 -0
  4. package/coverage/lcov-report/favicon.png +0 -0
  5. package/coverage/lcov-report/index.html +206 -0
  6. package/coverage/lcov-report/prettify.css +1 -0
  7. package/coverage/lcov-report/prettify.js +2 -0
  8. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  9. package/coverage/lcov-report/sorter.js +210 -0
  10. package/coverage/lcov.info +7675 -0
  11. package/dist/src/implementation.d.ts +13 -0
  12. package/dist/src/implementation.js +13 -0
  13. package/dist/src/implementation.js.map +1 -0
  14. package/dist/src/index.d.ts +1 -0
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/open-popup.d.ts +37 -13
  18. package/dist/src/open-popup.js +79 -12
  19. package/dist/src/open-popup.js.map +1 -1
  20. package/dist/src/ox-floating-overlay.d.ts +4 -1
  21. package/dist/src/ox-floating-overlay.js +19 -2
  22. package/dist/src/ox-floating-overlay.js.map +1 -1
  23. package/dist/src/ox-popup-actions.d.ts +36 -0
  24. package/dist/src/ox-popup-actions.js +129 -0
  25. package/dist/src/ox-popup-actions.js.map +1 -0
  26. package/dist/src/ox-prompt.d.ts +9 -3
  27. package/dist/src/ox-prompt.js +50 -42
  28. package/dist/src/ox-prompt.js.map +1 -1
  29. package/dist/src/popup-result.d.ts +19 -0
  30. package/dist/src/popup-result.js +53 -0
  31. package/dist/src/popup-result.js.map +1 -0
  32. package/dist/test/office-control-height.test.d.ts +1 -0
  33. package/dist/test/office-control-height.test.js +48 -0
  34. package/dist/test/office-control-height.test.js.map +1 -0
  35. package/dist/test/ox-popup-actions.test.d.ts +3 -0
  36. package/dist/test/ox-popup-actions.test.js +104 -0
  37. package/dist/test/ox-popup-actions.test.js.map +1 -0
  38. package/dist/test/ox-prompt-actions.test.d.ts +1 -0
  39. package/dist/test/ox-prompt-actions.test.js +77 -0
  40. package/dist/test/ox-prompt-actions.test.js.map +1 -0
  41. package/dist/test/popup-result.test.d.ts +1 -0
  42. package/dist/test/popup-result.test.js +114 -0
  43. package/dist/test/popup-result.test.js.map +1 -0
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +14 -5
  46. package/test/office-control-height.test.ts +58 -0
  47. package/test/ox-popup-actions.test.ts +130 -0
  48. package/test/ox-prompt-actions.test.ts +99 -0
  49. package/test/popup-result.test.ts +150 -0
@@ -0,0 +1,58 @@
1
+ import { expect } from '@open-wc/testing'
2
+
3
+ import { ACTION_HEIGHT_TOKENS, ButtonContainerStyles, CommonHeaderStyles, CommonPopupStyles } from '@operato/styles'
4
+
5
+ import { OxPopupActions } from '../src/ox-popup-actions.js'
6
+
7
+ /**
8
+ * Office button heights come from one token per use (ADR-0060 decision 6): 32px for action buttons,
9
+ * 26px in a grid cell, the same for mouse and finger (WCAG 2.2 AA, user decision 2026-09-17).
10
+ *
11
+ * The finger branch cannot be emulated in the test browser, so these read the stylesheets: every
12
+ * height is the token with the shared default, and no finger branch brings back a height of its own.
13
+ * Shop-floor popups raise the token themselves (operato-plant checks that on its side).
14
+ */
15
+
16
+ const text = (styles: unknown) =>
17
+ (Array.isArray(styles) ? styles : [styles]).map(s => String((s as { cssText?: string }).cssText ?? s)).join('\n')
18
+
19
+ const coarse = (css: string) => css.slice(css.indexOf('(pointer: coarse)'))
20
+
21
+ describe('office control height', () => {
22
+ it('the defaults are 32 for action buttons and 26 in a grid cell', () => {
23
+ expect(ACTION_HEIGHT_TOKENS).to.deep.equal({
24
+ '--context-action-height': 32,
25
+ '--popup-action-height': 32,
26
+ '--form-action-height': 32,
27
+ '--cell-action-height': 26
28
+ })
29
+ })
30
+
31
+ it('ox-popup-actions: the popup token, with no finger height of its own', () => {
32
+ const css = text(OxPopupActions.styles)
33
+ expect(css).to.contain('--_height: var(--popup-action-height, 32px)')
34
+ expect(coarse(css)).to.not.contain('--_height')
35
+ expect(css).to.not.match(/max\(\d+px/)
36
+ })
37
+
38
+ it('CommonPopupStyles .buttons: the popup token for mouse and finger', () => {
39
+ const css = text(CommonPopupStyles)
40
+ expect(css).to.contain('--md-filled-button-container-height: var(--popup-action-height, 32px)')
41
+ expect(coarse(css)).to.contain('min-height: var(--popup-action-height, 32px)')
42
+ expect(css).to.not.match(/max\(\d+px/)
43
+ })
44
+
45
+ it('CommonHeaderStyles .footer: the old popup footer reads the same popup token', () => {
46
+ const css = text(CommonHeaderStyles)
47
+ expect(css).to.contain('min-height: var(--popup-action-height, 32px)')
48
+ expect(css).to.not.contain('min-height: 30px')
49
+ })
50
+
51
+ it('ButtonContainerStyles: the page action token, and no finger branch', () => {
52
+ const css = text(ButtonContainerStyles)
53
+ expect(css).to.contain('height: var(--context-action-height, 32px)')
54
+ expect(css).to.contain('--md-filled-button-container-height: var(--context-action-height, 32px)')
55
+ expect(css).to.not.contain('--context-action-height-touch')
56
+ expect(css).to.not.contain('(pointer: coarse)')
57
+ })
58
+ })
@@ -0,0 +1,130 @@
1
+ import '@material/web/button/filled-button.js'
2
+ import '@material/web/button/text-button.js'
3
+
4
+ import { expect, fixture } from '@open-wc/testing'
5
+ import { html } from 'lit'
6
+
7
+ import '../src/ox-popup-actions.js'
8
+
9
+ /**
10
+ * The action row owns order, height, shape and the destructive colour - so a popup cannot get them
11
+ * wrong by writing its markup differently. These run with a mouse pointer (the test browser); the
12
+ * finger bar is a media query of the same element.
13
+ *
14
+ * The expected numbers are written out (32, 68, 999em) rather than read from the stylesheet, so a
15
+ * change to the stylesheet shows up here as a decision, not as a silent follow.
16
+ */
17
+
18
+ const left = (el: Element) => el.getBoundingClientRect().left
19
+ const height = (el: Element) => Math.round(el.getBoundingClientRect().height)
20
+
21
+ async function row(template: ReturnType<typeof html>) {
22
+ const host = await fixture<HTMLElement>(html`<div style="width: 600px">${template}</div>`)
23
+ const actions = host.querySelector('ox-popup-actions') as HTMLElement & { updateComplete: Promise<unknown> }
24
+ await actions.updateComplete
25
+ await Promise.all(
26
+ Array.from(actions.querySelectorAll('md-filled-button, md-text-button')).map(
27
+ b => (b as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete
28
+ )
29
+ )
30
+ return actions
31
+ }
32
+
33
+ describe('ox-popup-actions', () => {
34
+ it('renders start, cancel, confirm in that order whatever the markup order', async () => {
35
+ const actions = await row(html`
36
+ <ox-popup-actions>
37
+ <md-filled-button slot="confirm" id="confirm">save</md-filled-button>
38
+ <md-text-button slot="cancel" id="cancel">cancel</md-text-button>
39
+ <md-text-button slot="start" id="start">delete</md-text-button>
40
+ </ox-popup-actions>
41
+ `)
42
+
43
+ const start = actions.querySelector('#start')!
44
+ const cancel = actions.querySelector('#cancel')!
45
+ const confirm = actions.querySelector('#confirm')!
46
+
47
+ expect(left(start)).to.be.lessThan(left(cancel))
48
+ expect(left(cancel)).to.be.lessThan(left(confirm))
49
+ })
50
+
51
+ it('keeps the confirm button at the right edge', async () => {
52
+ const actions = await row(html`
53
+ <ox-popup-actions>
54
+ <md-filled-button slot="confirm" id="confirm">save</md-filled-button>
55
+ <md-text-button slot="cancel">cancel</md-text-button>
56
+ </ox-popup-actions>
57
+ `)
58
+
59
+ const confirm = actions.querySelector('#confirm')!
60
+ expect(Math.round(confirm.getBoundingClientRect().right)).to.equal(Math.round(actions.getBoundingClientRect().right))
61
+ })
62
+
63
+ it('gives Material buttons and plain buttons the same 32px height', async () => {
64
+ const actions = await row(html`
65
+ <ox-popup-actions>
66
+ <button slot="cancel" id="plain">cancel</button>
67
+ <md-filled-button slot="confirm" id="md">save</md-filled-button>
68
+ </ox-popup-actions>
69
+ `)
70
+
71
+ expect(height(actions.querySelector('#md')!)).to.equal(32)
72
+ expect(height(actions.querySelector('#plain')!)).to.equal(32)
73
+ })
74
+
75
+ it('uses pill shapes inside the popup card', async () => {
76
+ const actions = await row(html`
77
+ <ox-popup-actions>
78
+ <button slot="cancel" id="plain">cancel</button>
79
+ <md-filled-button slot="confirm" id="md">save</md-filled-button>
80
+ </ox-popup-actions>
81
+ `)
82
+
83
+ const md = actions.querySelector('#md')!
84
+ expect(getComputedStyle(md).getPropertyValue('--md-filled-button-container-shape').trim()).to.equal('999em')
85
+ expect(getComputedStyle(actions.querySelector('#plain')!).borderTopLeftRadius).to.not.equal('0px')
86
+ })
87
+
88
+ it('lets a screen raise the height through --popup-action-height', async () => {
89
+ const actions = await row(html`
90
+ <ox-popup-actions style="--popup-action-height: 68px">
91
+ <button slot="cancel" id="plain">cancel</button>
92
+ <md-filled-button slot="confirm" id="md">save</md-filled-button>
93
+ </ox-popup-actions>
94
+ `)
95
+
96
+ expect(height(actions.querySelector('#md')!)).to.equal(68)
97
+ expect(height(actions.querySelector('#plain')!)).to.equal(68)
98
+ })
99
+
100
+ it('paints a destructive action with the error colour', async () => {
101
+ const actions = await row(html`
102
+ <ox-popup-actions style="--md-sys-color-error: rgb(190, 12, 20)">
103
+ <md-text-button slot="start" destructive id="side">delete</md-text-button>
104
+ <md-filled-button slot="confirm" destructive id="main">delete all</md-filled-button>
105
+ <button slot="cancel" id="plain" destructive>cancel</button>
106
+ </ox-popup-actions>
107
+ `)
108
+
109
+ const side = getComputedStyle(actions.querySelector('#side')!)
110
+ const main = getComputedStyle(actions.querySelector('#main')!)
111
+
112
+ expect(side.getPropertyValue('--md-text-button-label-text-color').trim()).to.equal('rgb(190, 12, 20)')
113
+ expect(main.getPropertyValue('--md-filled-button-container-color').trim()).to.equal('rgb(190, 12, 20)')
114
+ })
115
+
116
+ it('never paints cancel as destructive', async () => {
117
+ const actions = await row(html`
118
+ <ox-popup-actions style="--md-sys-color-error: rgb(190, 12, 20)">
119
+ <md-text-button slot="cancel" destructive id="cancel">cancel</md-text-button>
120
+ <button slot="cancel" destructive id="plain">cancel</button>
121
+ <md-filled-button slot="confirm">save</md-filled-button>
122
+ </ox-popup-actions>
123
+ `)
124
+
125
+ expect(getComputedStyle(actions.querySelector('#cancel')!).getPropertyValue('--md-text-button-label-text-color').trim()).to.not.equal(
126
+ 'rgb(190, 12, 20)'
127
+ )
128
+ expect(getComputedStyle(actions.querySelector('#plain')!).color).to.not.equal('rgb(190, 12, 20)')
129
+ })
130
+ })
@@ -0,0 +1,99 @@
1
+ import { expect } from '@open-wc/testing'
2
+
3
+ import { OxPrompt } from '../src/ox-prompt.js'
4
+
5
+ /**
6
+ * OxPrompt is the first real consumer of the shared action row. Before, its buttons were centred,
7
+ * confirm stood left of cancel, and both had the theme's 5px corner inside a floating card - a row of
8
+ * its own, unlike every popup that opened it.
9
+ *
10
+ * Checked here: the row is the shared one, confirm is on the right, heights match, a destructive
11
+ * confirmation is painted and focuses cancel, and the promise still answers true / false.
12
+ */
13
+
14
+ const settle = () => new Promise(resolve => setTimeout(resolve, 50))
15
+
16
+ const TIMEOUT = 'TIMEOUT'
17
+ function within<T>(promise: Promise<T>, ms = 1500): Promise<T | typeof TIMEOUT> {
18
+ return Promise.race([promise, new Promise<typeof TIMEOUT>(resolve => setTimeout(() => resolve(TIMEOUT), ms))])
19
+ }
20
+
21
+ async function openPrompt(destructive = false) {
22
+ const answer = OxPrompt.open({
23
+ title: 'remove?',
24
+ text: 'this cannot be undone',
25
+ confirmButton: { text: 'remove', destructive },
26
+ cancelButton: { text: 'cancel' },
27
+ preventCloseOnBlur: true
28
+ })
29
+
30
+ await settle()
31
+
32
+ const prompts = document.body.querySelectorAll('ox-prompt')
33
+ const prompt = prompts[prompts.length - 1] as OxPrompt
34
+ await prompt.updateComplete
35
+
36
+ const root = prompt.renderRoot as ShadowRoot
37
+ const actions = root.querySelector('ox-popup-actions') as HTMLElement & { updateComplete: Promise<unknown> }
38
+ await actions?.updateComplete
39
+
40
+ const confirm = root.querySelector('#confirm') as HTMLElement & { updateComplete: Promise<unknown> }
41
+ const cancel = root.querySelector('#cancel') as HTMLElement & { updateComplete: Promise<unknown> }
42
+ await Promise.all([confirm?.updateComplete, cancel?.updateComplete])
43
+
44
+ return { answer, prompt, root, actions, confirm, cancel }
45
+ }
46
+
47
+ describe('OxPrompt uses the shared action row', () => {
48
+ before(() => {
49
+ /* No theme is loaded in the test page; an undefined var() would make every colour read empty. */
50
+ document.body.style.setProperty('--md-sys-color-error', 'rgb(190, 12, 20)')
51
+ })
52
+
53
+ afterEach(() => {
54
+ /* A failed assertion before the click leaves its prompt open; do not let it leak into the next test. */
55
+ document.body.querySelectorAll('ox-prompt').forEach(p => (p as OxPrompt).close())
56
+ })
57
+
58
+ it('renders its buttons in ox-popup-actions, confirm right of cancel, same height', async () => {
59
+ const { answer, actions, confirm, cancel } = await openPrompt()
60
+
61
+ expect(actions, 'ox-popup-actions').to.exist
62
+ expect(confirm.getAttribute('slot')).to.equal('confirm')
63
+ expect(cancel.getAttribute('slot')).to.equal('cancel')
64
+ expect(cancel.getBoundingClientRect().left).to.be.lessThan(confirm.getBoundingClientRect().left)
65
+ expect(Math.round(confirm.getBoundingClientRect().height)).to.equal(32)
66
+ expect(Math.round(cancel.getBoundingClientRect().height)).to.equal(32)
67
+
68
+ cancel.click()
69
+ expect(await within(answer)).to.equal(false)
70
+ })
71
+
72
+ it('answers true on confirm', async () => {
73
+ const { answer, confirm } = await openPrompt()
74
+
75
+ confirm.click()
76
+ expect(await within(answer)).to.equal(true)
77
+ })
78
+
79
+ it('focuses confirm for an ordinary question', async () => {
80
+ const { answer, root, confirm, cancel } = await openPrompt()
81
+
82
+ /* Compare ids, not elements: a failing element comparison makes chai render the DOM and hang. */
83
+ expect((root.activeElement as HTMLElement | null)?.id).to.equal('confirm')
84
+
85
+ cancel.click()
86
+ await within(answer)
87
+ })
88
+
89
+ it('paints a destructive confirmation and focuses cancel', async () => {
90
+ const { answer, root, confirm, cancel } = await openPrompt(true)
91
+
92
+ expect(confirm.hasAttribute('destructive')).to.equal(true)
93
+ expect(getComputedStyle(confirm).getPropertyValue('--md-filled-button-container-color').trim()).to.equal('rgb(190, 12, 20)')
94
+ expect((root.activeElement as HTMLElement | null)?.id).to.equal('cancel')
95
+
96
+ cancel.click()
97
+ expect(await within(answer)).to.equal(false)
98
+ })
99
+ })
@@ -0,0 +1,150 @@
1
+ import { expect } from '@open-wc/testing'
2
+ import { html } from 'lit'
3
+
4
+ import { closePopup, openPopup } from '../src/open-popup.js'
5
+ import { OxFloatingOverlay } from '../src/ox-floating-overlay.js'
6
+
7
+ /**
8
+ * The popup result settles exactly once, with the right value, whichever way the popup closes -
9
+ * for the default implementation (no @operato/layout loaded).
10
+ *
11
+ * The implementation's one promise is "call onclosed once when the popup is gone". Before this test
12
+ * the default implementation kept it only for handle.close(): ESC, the backdrop and X removed the
13
+ * overlay without telling the handle, so an awaited result would have hung forever.
14
+ *
15
+ * The same table runs for the layout implementation in @operato/layout/test. A new implementation
16
+ * adds its own row there.
17
+ */
18
+
19
+ const TIMEOUT = 'TIMEOUT'
20
+
21
+ /** Never await a result bare - a broken implementation must fail the test, not hang the runner. */
22
+ function within<T>(promise: Promise<T>, ms = 2000): Promise<T | typeof TIMEOUT> {
23
+ return Promise.race([promise, new Promise<typeof TIMEOUT>(resolve => setTimeout(() => resolve(TIMEOUT), ms))])
24
+ }
25
+
26
+ function overlayOf(handle: { name: string }): OxFloatingOverlay {
27
+ return document.getElementById(handle.name) as OxFloatingOverlay
28
+ }
29
+
30
+ async function open(options = {}) {
31
+ const handle = openPopup<string>(html`<div class="content">content</div>`, { backdrop: true, ...options })
32
+ let onclosedCalls = 0
33
+ handle.onclosed = () => onclosedCalls++
34
+
35
+ const overlay = overlayOf(handle)
36
+ await overlay.updateComplete
37
+
38
+ return {
39
+ handle,
40
+ overlay,
41
+ content: overlay.querySelector('.content') as HTMLElement,
42
+ onclosedCalls: () => onclosedCalls
43
+ }
44
+ }
45
+
46
+ /** Lets any late second settle or second onclosed show up before counting. */
47
+ const settleTurn = () => new Promise(resolve => setTimeout(resolve, 50))
48
+
49
+ describe('openPopup result - default implementation', () => {
50
+ it('handle.close(value) settles with that value', async () => {
51
+ const { handle, onclosedCalls } = await open()
52
+
53
+ handle.close('saved')
54
+
55
+ expect(await within(handle.result)).to.equal('saved')
56
+ await settleTurn()
57
+ expect(handle.closed).to.equal(true)
58
+ expect(onclosedCalls()).to.equal(1)
59
+ })
60
+
61
+ it('closePopup(element, value) from the content settles with that value', async () => {
62
+ const { handle, content, onclosedCalls } = await open()
63
+
64
+ closePopup(content, 'picked')
65
+
66
+ expect(await within(handle.result)).to.equal('picked')
67
+ await settleTurn()
68
+ expect(handle.closed).to.equal(true)
69
+ expect(onclosedCalls()).to.equal(1)
70
+ })
71
+
72
+ it('closePopup(element) without a value settles undefined', async () => {
73
+ const { handle, content } = await open()
74
+
75
+ closePopup(content)
76
+
77
+ expect(await within(handle.result)).to.equal(undefined)
78
+ })
79
+
80
+ it('ESC settles undefined and reports closed', async () => {
81
+ const { handle, onclosedCalls } = await open({ escapable: true })
82
+
83
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
84
+
85
+ expect(await within(handle.result)).to.equal(undefined)
86
+ await settleTurn()
87
+ expect(handle.closed).to.equal(true)
88
+ expect(onclosedCalls()).to.equal(1)
89
+ })
90
+
91
+ it('backdrop click settles undefined and reports closed', async () => {
92
+ const { handle, overlay, onclosedCalls } = await open()
93
+
94
+ const backdrop = overlay.renderRoot.querySelector('#backdrop') as HTMLElement
95
+ expect(backdrop, 'backdrop element').to.exist
96
+ backdrop.click()
97
+
98
+ expect(await within(handle.result)).to.equal(undefined)
99
+ await settleTurn()
100
+ expect(handle.closed).to.equal(true)
101
+ expect(onclosedCalls()).to.equal(1)
102
+ })
103
+
104
+ it('the X button settles undefined and reports closed', async () => {
105
+ const { handle, overlay, onclosedCalls } = await open({ closable: true, title: 'x' })
106
+
107
+ const closer = overlay.renderRoot.querySelector('[closable][close]') as HTMLElement
108
+ expect(closer, 'close button').to.exist
109
+ closer.click()
110
+
111
+ expect(await within(handle.result)).to.equal(undefined)
112
+ await settleTurn()
113
+ expect(handle.closed).to.equal(true)
114
+ expect(onclosedCalls()).to.equal(1)
115
+ })
116
+
117
+ it('the first close request wins: closePopup then handle.close', async () => {
118
+ const { handle, content, onclosedCalls } = await open()
119
+
120
+ closePopup(content, 'first')
121
+ handle.close('second')
122
+
123
+ expect(await within(handle.result)).to.equal('first')
124
+ await settleTurn()
125
+ expect(onclosedCalls()).to.equal(1)
126
+ })
127
+
128
+ it('the first close request wins: handle.close twice', async () => {
129
+ const { handle, onclosedCalls } = await open()
130
+
131
+ handle.close('first')
132
+ handle.close('second')
133
+
134
+ expect(await within(handle.result)).to.equal('first')
135
+ await settleTurn()
136
+ expect(onclosedCalls()).to.equal(1)
137
+ })
138
+
139
+ it('an onclosed assigned before the change is still called once', async () => {
140
+ const handle = openPopup(html`<div>legacy</div>`)
141
+ let calls = 0
142
+ handle.onclosed = () => calls++
143
+
144
+ handle.close()
145
+
146
+ await within(handle.result)
147
+ await settleTurn()
148
+ expect(calls).to.equal(1)
149
+ })
150
+ })