Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export default config => config.set({
executors: 4,
},
helpfulReporter: {
// eslint-disable-next-line no-undef
renderOnRunCompleteOnly: Boolean(process.env.CI || process.env.GITHUB_ACTIONS),
removeTail: true,
colorBrowser: 205,
colorConsoleLogs: 45,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"dev": "node ./scripts/test.ts",
"watch": "npm-watch",
"test": "node ./scripts/test.ts && karma start --single-run",
"lint": "eslint **/*.ts",
"lint": "eslint \"**/*.ts\"",
"docs:build": "node ./scripts/docs-build.ts",
"clean": "node ./scripts/clean.ts",
"typescript": "node ./node_modules/typescript-7/bin/tsc --build --noEmit",
Expand Down
38 changes: 37 additions & 1 deletion packages/Accordion/Accordion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ describe('AccordionItem', () => {
</mo-accordion-item>
`)

const slottedHeadingFixture = new ComponentTestFixture<AccordionItem>(html`
<mo-accordion-item heading='Shipping'>
<span slot='heading'>Delivery</span>
<p style='height: 60px; margin: 0'>Content</p>
</mo-accordion-item>
`)

const summary = () => fixture.component.renderRoot.querySelector('summary')!

/** A "details" element fires its "toggle" asynchronously, so a click is only followed one task later. */
Expand Down Expand Up @@ -50,7 +57,7 @@ describe('AccordionItem', () => {
await new Promise(resolve => setTimeout(resolve, 500))

expect(closedHeight).toBe(summary().getBoundingClientRect().height)
expect(fixture.component.detailsElement.getBoundingClientRect().height).toBeGreaterThan(closedHeight)
expect(fixture.component.detailsElement.getBoundingClientRect().height).toBeGreaterThanOrEqual(closedHeight)
})

it('should open when its summary is clicked and close when it is clicked again', async () => {
Expand All @@ -77,6 +84,12 @@ describe('AccordionItem', () => {
expect(handler.calls.mostRecent().args[0].detail).toBe(true)
})

it('should give the "heading" slot precedence over the "heading" attribute', () => {
const slot = slottedHeadingFixture.component.renderRoot.querySelector<HTMLSlotElement>('slot[name=heading]')!

expect(slot.assignedNodes({ flatten: true }).map(node => node.textContent)).toEqual(['Delivery'])
})

describe('disabled', () => {
beforeEach(async () => {
fixture.component.disabled = true
Expand Down Expand Up @@ -112,6 +125,13 @@ describe('Accordion', () => {
</mo-accordion>
`)

const valuelessItemFixture = new ComponentTestFixture<Accordion>(html`
<mo-accordion>
<mo-accordion-item value='shipping' heading='Shipping'>Shipping</mo-accordion-item>
<mo-accordion-item heading='Notes'>Notes</mo-accordion-item>
</mo-accordion>
`)

const item = (value: string) => fixture.component.items.find(item => item.value === value)!

const openValues = () => fixture.component.openItems.map(item => item.value)
Expand Down Expand Up @@ -187,6 +207,22 @@ describe('Accordion', () => {
expect(item('shipping').open).toBe(true)
})

it('should let an item without a value take part in exclusivity but not in the value', async () => {
const [named, unnamed] = valuelessItemFixture.component.items as [AccordionItem, AccordionItem]
await settle(valuelessItemFixture.component)

named.open = true
await settle(valuelessItemFixture.component)
expect(valuelessItemFixture.component.value).toBe('shipping')

unnamed.open = true
await settle(valuelessItemFixture.component)

expect(named.open).toBe(false)
expect(unnamed.open).toBe(true)
expect(valuelessItemFixture.component.value).toBeUndefined()
})

describe('multiple', () => {
beforeEach(async () => {
fixture.component.multiple = true
Expand Down
68 changes: 62 additions & 6 deletions packages/Alert/Alert.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import { html, render } from '@a11d/lit'
import { ComponentTestFixture } from '@a11d/lit-testing'
import type { ExpandCollapseIconButton } from '@3mo/expand-collapse-icon-button'
import { Alert, AlertType } from './Alert.js'
import './index.js'

describe('Alert', () => {
const fixture = new ComponentTestFixture<Alert>('mo-alert')

const slotElement = () => fixture.component.renderRoot.querySelector('slot:not([name])')!
const slotHeight = () => getComputedStyle(slotElement()).height

const settleContentTransition = () => new Promise(resolve => setTimeout(resolve, 350))

const contentHeight = 40

const assignContent = async () => {
render(html`<div style='height: ${contentHeight}px'>Test content</div>`, fixture.component)
await fixture.update()
}

it('should have the default type "info"', () => {
expect(fixture.component.type).toBe(AlertType.Info)
})

it('should expose role "alert" so its content is announced', () => {
const grid = fixture.component.renderRoot.querySelector('mo-grid')!

expect(grid.getAttribute('role')).toBe('alert')
expect(grid.contains(slotElement())).toBe(true)
})

describe('Type icon', () => {
const getIcon = () => fixture.component.renderRoot.querySelector('mo-icon')

Expand Down Expand Up @@ -78,19 +99,54 @@ describe('Alert', () => {
expect(fixture.component.open).toBe(false)
expect(openChangeSpy).toHaveBeenCalledWith(false)
})

it('should not dispatch "openChange" for a programmatically set "open", as only the interaction reports', async () => {
const handler = jasmine.createSpy('openChange')
fixture.component.addEventListener('openChange', handler)
fixture.component.collapsible = true
fixture.component.heading = 'Test'
await fixture.updateComplete

fixture.component.open = true
await fixture.updateComplete

expect(fixture.component.renderRoot.querySelector<ExpandCollapseIconButton>('mo-expand-collapse-icon-button')?.open).toBe(true)
expect(handler).not.toHaveBeenCalled()
})
})

describe('Default slot', () => {
const getSlotHeight = () => getComputedStyle(fixture.component.renderRoot.querySelector('slot:not([name])')!).height
it('should set height to 0 when there are no assigned content', () => expect(slotHeight()).toBe('0px'))

it('should size the content to its own height once content is assigned', async () => {
await assignContent()
await settleContentTransition()

expect(parseFloat(slotHeight())).toBeCloseTo(contentHeight, 0)
})
})

it('should set height to 0 when there are no assigned content', () => expect(getSlotHeight()).toBe('0px'))
describe('Collapsible content', () => {
beforeEach(async () => {
fixture.component.heading = 'Test'
fixture.component.collapsible = true
await assignContent()
await settleContentTransition()
})

it('should set height to auto when there are assigned content', async () => {
fixture.component.textContent = 'Test content'
it('should collapse the content to zero height while collapsible and not open', () => {
expect(fixture.component.open).toBe(false)
expect(slotElement().hasAttribute('data-collapsed')).toBe(true)
expect(slotHeight()).toBe('0px')
})

await fixture.update()
it('should reveal the content once opened', async () => {
fixture.component.open = true
await fixture.updateComplete
await settleContentTransition()

expect(getSlotHeight()).not.toBe('auto')
expect(slotElement().hasAttribute('data-collapsed')).toBe(false)
expect(parseFloat(slotHeight())).toBeCloseTo(contentHeight, 0)
})
})
})
14 changes: 14 additions & 0 deletions packages/Anchor/Anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ describe('Anchor', () => {
})
}

it('should not prevent the click when an "href" is set, as only void anchors are inert', async () => {
fixture.component.href = 'https://www.3mo.de/'
await fixture.updateComplete
let preventedByComponent: boolean | undefined

fixture.component.addEventListener('click', event => {
preventedByComponent = event.defaultPrevented
event.preventDefault()
})
fixture.component.dispatchEvent(new MouseEvent('click', { cancelable: true }))

expect(preventedByComponent).toBe(false)
})

describe('foreground color', () => {
const blue = 'rgb(0, 0, 255)'
const red = 'rgb(255, 0, 0)'
Expand Down
148 changes: 142 additions & 6 deletions packages/Button/Button.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,148 @@
import { html } from '@a11d/lit'
import { ComponentTestFixture } from '@a11d/lit-testing'
import { type Button } from './Button.js'
import { type Button, ButtonType } from './Button.js'
import { type Icon } from '@3mo/icon'
import './index.js'

type MdButton = HTMLElement & { disabled: boolean, updateComplete: Promise<unknown> }

const mdButtonSelector = 'md-text-button, md-outlined-button, md-filled-button, md-filled-tonal-button, md-elevated-button'

describe('Button', () => {
const fixture = new ComponentTestFixture<Button>('mo-button')
describe('type', () => {
const fixture = new ComponentTestFixture<Button>('mo-button')

const tagByType = new Map([
[ButtonType.Text, 'md-text-button'],
[ButtonType.Outlined, 'md-outlined-button'],
[ButtonType.Tonal, 'md-filled-tonal-button'],
[ButtonType.Elevated, 'md-elevated-button'],
[ButtonType.Filled, 'md-filled-button'],
])

for (const [type, tagName] of tagByType) {
it(`should render a "${tagName}" element for the type "${type}"`, async () => {
fixture.component.type = type
await fixture.update()

const mdButton = fixture.component.renderRoot.querySelector(mdButtonSelector)
expect(mdButton?.tagName.toLowerCase()).toBe(tagName)
})
}

it('should reflect "type" as an attribute, which the padding and button-group styling key off', async () => {
const paddingOf = async () => {
const mdButton = fixture.component.renderRoot.querySelector<MdButton>(mdButtonSelector)!
await mdButton.updateComplete
return getComputedStyle(mdButton).getPropertyValue('--mo-button-default-horizontal-padding').trim()
}

expect(fixture.component.getAttribute('type')).toBe(ButtonType.Text)
expect(await paddingOf()).toBe('12px')

fixture.component.type = ButtonType.Filled
await fixture.update()

expect(fixture.component.getAttribute('type')).toBe(ButtonType.Filled)
expect(await paddingOf()).toBe('16px')
})
})

describe('disabled', () => {
const fixture = new ComponentTestFixture<Button>('mo-button')

const mdButton = () => fixture.component.renderRoot.querySelector<MdButton>(mdButtonSelector)!

it('should set pointer-events to "none" when disabled', async () => {
fixture.component.disabled = true
await fixture.updateComplete
expect(getComputedStyle(fixture.component).pointerEvents).toBe('none')
})

it('should tunnel "disabled" to the underlying md button', async () => {
fixture.component.disabled = true
await fixture.update()
expect(mdButton().disabled).toBe(true)
})

it('should not be focusable while disabled', async () => {
fixture.component.disabled = true
await fixture.update()
await mdButton().updateComplete
; (document.activeElement as HTMLElement | null)?.blur()

fixture.component.focus()

expect(fixture.component.shadowRoot!.activeElement).toBeNull()
expect(document.activeElement).not.toBe(fixture.component)
})
})

describe('content', () => {
describe('without slotted content', () => {
const fixture = new ComponentTestFixture<Button>(html`<mo-button startIcon='home' endIcon='close'>Label</mo-button>`)

it('should render a mo-icon for "startIcon" as the start slot\'s fallback', () => {
const icon = fixture.component.renderRoot.querySelector<Icon>('slot[name=start] > mo-icon')
expect(icon?.icon).toBe('home')
})

it('should render a mo-icon for "endIcon" as the end slot\'s fallback', () => {
const icon = fixture.component.renderRoot.querySelector<Icon>('slot[name=end] > mo-icon')
expect(icon?.icon).toBe('close')
})
})

describe('with slotted content', () => {
const fixture = new ComponentTestFixture<Button>(html`
<mo-button startIcon='home' endIcon='close'>
<span slot='start' id='slotted-start'>S</span>
<span slot='end' id='slotted-end'>E</span>
Label
</mo-button>
`)

for (const slotName of ['start', 'end']) {
it(`should let slotted content take precedence over the icon fallback of the "${slotName}" slot`, () => {
const slot = fixture.component.renderRoot.querySelector<HTMLSlotElement>(`slot[name=${slotName}]`)!
expect(slot.assignedElements().map(element => element.id)).toEqual([`slotted-${slotName}`])
})
}
})
})

describe('focus', () => {
const fixture = new ComponentTestFixture<Button>('mo-button')

it('should delegate focus to the underlying md button', async () => {
const mdButton = fixture.component.renderRoot.querySelector<MdButton>(mdButtonSelector)!
await mdButton.updateComplete
; (document.activeElement as HTMLElement | null)?.blur()

fixture.component.focus()

expect(fixture.component.shadowRoot!.activeElement).toBe(mdButton)
expect(document.activeElement).toBe(fixture.component)
})
})

describe('css parts', () => {
const fixture = new ComponentTestFixture<Button>('mo-button')

const selectorByPart = new Map([
['button', 'button'],
['ripple', 'md-ripple'],
['focus-ring', 'md-focus-ring'],
])

for (const [part, selector] of selectorByPart) {
it(`should export the css part "${part}" through the md button`, async () => {
const mdButton = fixture.component.renderRoot.querySelector<MdButton>(mdButtonSelector)!
await mdButton.updateComplete

it('should set pointer-events to "none" when disabled', async () => {
fixture.component.disabled = true
await fixture.updateComplete
expect(getComputedStyle(fixture.component).pointerEvents).toBe('none')
expect(mdButton.getAttribute('exportparts')?.split(',')).toContain(part)
expect((mdButton.shadowRoot!.querySelector(selector))?.getAttribute('part')?.split(' ')).toContain(part)
})
}
})
})
Loading