Skip to content

Commit

Permalink
feat: spin settings button during settings loading
Browse files Browse the repository at this point in the history
  • Loading branch information
khmm12 committed Jun 24, 2024
1 parent 1c65c8e commit 67f2448
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 28 deletions.
8 changes: 4 additions & 4 deletions src/components/Application/hooks/createSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export default function createSettingsDialog(): CreateSettingsDialogReturnValue

return {
$el: (
<ShowWithTransition when={showSettings()}>
<Suspense>
<Suspense>
<ShowWithTransition when={showSettings()}>
<SettingsDialog onClose={handleSettingsClose} onSaved={handleSettingsSaved} />
</Suspense>
</ShowWithTransition>
</ShowWithTransition>
</Suspense>
),
open: handleSettingsRequest,
}
Expand Down
8 changes: 5 additions & 3 deletions src/components/Footer/Footer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { fireEvent, render, screen } from '@test/helpers/solid'
import { fireEvent, render, screen, waitFor } from '@test/helpers/solid'
import Footer from '.'

vi.mock('@/components/Credits', () => ({
default: () => <div>Made by My Little Pony</div>,
}))

describe('Footer', () => {
it('renders a settings button', () => {
it('renders a settings button', async () => {
const handleSettingsRequest = vi.fn()
render(() => <Footer onSettingsRequest={handleSettingsRequest} />)

expect(screen.getByTitle('Open settings')).toBeInTheDocument()

fireEvent.click(screen.getByTitle('Open settings'))

expect(handleSettingsRequest).toBeCalled()
await waitFor(() => {
expect(handleSettingsRequest).toBeCalled()
})
})

it('renders credits', () => {
Expand Down
53 changes: 50 additions & 3 deletions src/components/SettingsButton/SettingsButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,65 @@
import { fireEvent, render } from '@test/helpers/solid'
import { createResource, createSignal, Show, Suspense } from 'solid-js'
import { fireEvent, render, renderHook, waitFor } from '@test/helpers/solid'
import SettingsButton from '.'

describe('SettingsButton', () => {
it('renders a button', () => {
it('renders a button', async () => {
const handleClick = vi.fn()
const r = render(() => <SettingsButton onClick={handleClick} />)

const button = r.getByRole('button')

expect(button).toBeInTheDocument()
expect(button).toMatchSnapshot()
expect(button).toHaveAttribute('aria-disabled', 'false')
expect(button).toHaveAccessibleName('Open settings')

fireEvent.click(button)

expect(handleClick).toBeCalled()
await waitFor(() => {
expect(handleClick).toBeCalled()
})
})

it('handles deferred resources', async () => {
const [isShown, setIsShown] = renderHook(() => createSignal(false)).result
const [p, resolve] = deferred<null>()
const [resource] = renderHook(() => createResource(async () => await p)).result

const r = render(() => (
<>
<SettingsButton onClick={() => setIsShown(true)} />
<Show when={isShown()}>
<Suspense>
<div>{resource()}</div>
</Suspense>
</Show>
</>
))

const button = r.getByRole('button')

fireEvent.click(button)

expect(button).toHaveAttribute('aria-disabled', 'true')
expect(button).toHaveAccessibleName('Opening settings')

resolve(null)

await waitFor(() => {
expect(button).toHaveAttribute('aria-disabled', 'false')
expect(button).toHaveAccessibleName('Open settings')
})
})
})

function deferred<T>(): [p: Promise<T>, resolve: (val: T) => void] {
let r: (val: T) => void

const p = new Promise<T>((resolve) => {
r = resolve
})

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [p, r!]
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

exports[`SettingsButton > renders a button 1`] = `
<button
aria-disabled="false"
class="button_b7837ym"
title="Open settings"
type="button"
Expand Down
67 changes: 51 additions & 16 deletions src/components/SettingsButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { type JSX, onCleanup } from 'solid-js'
import {
type Accessor,
createComputed,
createEffect,
createSignal,
type JSX,
onCleanup,
startTransition,
untrack,
} from 'solid-js'
import { SettingsIcon } from '@/components/Icon'
import * as css from './styles'

Expand All @@ -8,31 +17,57 @@ interface SettingsButtonProps {

export default function SettingsButton(props: SettingsButtonProps): JSX.Element {
let $svg: SVGSVGElement | undefined
let animation: Animation | undefined

onCleanup(() => {
animation?.cancel()
})
const [isLoading, setIsLoading] = createSignal(false)

createIconAnimation(() => $svg, isLoading)

const handleClick = (): void => {
if ($svg != null) {
animation ??= animate($svg)
animation?.play()
}
props.onClick?.()
if (isLoading()) return

setIsLoading(true)
startTransition(() => props.onClick?.())
.catch(() => {})
.finally(() => setIsLoading(false))
}

return (
<button class={css.button} type="button" title="Open settings" onClick={handleClick}>
<button
class={css.button}
type="button"
aria-disabled={isLoading()}
title={isLoading() ? 'Opening settings' : 'Open settings'}
onClick={handleClick}
>
<SettingsIcon ref={$svg} aria-hidden class={css.svg} />
</button>
)
}

function animate($el: Element): Animation | undefined {
if (typeof $el.animate === 'function')
return $el.animate([{ transform: 'rotate(180deg)' }], {
duration: 300,
easing: 'ease-out',
function createIconAnimation(svg: Accessor<SVGSVGElement | undefined>, when: Accessor<boolean>): void {
const [isRunning, setIsRunning] = createSignal(false)

createComputed(() => {
if (when()) setIsRunning(true)
})

// Wait for animation iteration to finish, then stop
createEffect(() => {
if (typeof AnimationEvent === 'undefined') return

const $el = untrack(svg)
if ($el == null || !isRunning()) return

const handleAnimationIteration = (): void => {
if (!untrack(when)) setIsRunning(false)
}

$el.addEventListener('animationiteration', handleAnimationIteration)
$el.classList.add('is-animated')

onCleanup(() => {
$el.classList.remove('is-animated')
$el.removeEventListener('animationiteration', handleAnimationIteration)
})
})
}
19 changes: 18 additions & 1 deletion src/components/SettingsButton/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ export const button = css`
background: none;
border: none;
cursor: pointer;
transform: rotate(0deg);
transition: 0.1s color ease-out;
&[aria-disabled='true'] {
cursor: default;
}
&:hover {
color: ${black};
}
Expand All @@ -38,4 +41,18 @@ export const svg = css`
.${button}:hover > & {
transform: rotate(22.5deg);
}
&.is-animated {
animation: spin 0.3s ease-out infinite forwards;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`
8 changes: 7 additions & 1 deletion src/components/SettingsDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type JSX, Suspense } from 'solid-js'
import { type Accessor, type JSX, Suspense } from 'solid-js'
import { SettingsIcon } from '@/components/Icon'
import Modal from '@/components/Modal'
import createSettingsStorage, { type Settings } from '@/hooks/createSettingsStorage'
Expand All @@ -20,8 +20,14 @@ export default function SettingsDialog(props: SettingsDialogProps): JSX.Element
return (
<Modal icon={<SettingsIcon />} title="Settings" onClose={props.onClose}>
<Suspense fallback={<span aria-busy>Loading</span>}>
{/* Trigger suspense */ read(settings)}
<SettingsForm initialValues={settings()} onSubmit={handleSubmit} />
</Suspense>
</Modal>
)
}

function read<T>(v: Accessor<T>): null {
v()
return null
}

0 comments on commit 67f2448

Please sign in to comment.