Skip to content

Commit 88a68ca

Browse files
committed
better gradient import, more tests
1 parent 93d2db2 commit 88a68ca

8 files changed

Lines changed: 314 additions & 102 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, master, develop, "feature/**" ]
6+
pull_request:
7+
branches: [ "**" ]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Node.js
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: '20'
20+
cache: 'pnpm'
21+
22+
- name: Setup pnpm
23+
uses: pnpm/action-setup@v4
24+
with:
25+
version: 10
26+
run_install: false
27+
28+
- name: Install dependencies
29+
run: pnpm install --frozen-lockfile
30+
31+
- name: Lint
32+
run: pnpm lint
33+
34+
- name: Test
35+
run: pnpm test

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/GradientImportDialog.svelte

Lines changed: 125 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,30 @@
22
import { onMount } from 'svelte'
33
import { parseGradient, ParseError } from '../lib/parseGradient'
44
import { applyParsedToStores } from '../lib/importGradient'
5+
import ImportEditor from './import/ImportEditor.svelte'
6+
import ImportActions from './import/ImportActions.svelte'
57
68
let open = false
9+
let dialog
10+
let textareaEl // will hold ImportEditor component instance for focusing via editor.focus()
11+
const titleId = 'import-gradient-title'
12+
const inputId = 'import-gradient-input'
13+
14+
// input state
15+
let gradientText = ''
16+
let error = ''
17+
let valid = false
18+
let timer
19+
720
export function show() {
821
open = true
9-
// open dialog on next tick
1022
queueMicrotask(() => {
1123
dialog?.showModal()
12-
// focus textarea for good UX
13-
setTimeout(() => textareaEl?.focus(), 0)
24+
// focus the editor's textarea via its exposed focus method
25+
setTimeout(() => textareaEl?.focus?.(), 0)
1426
})
1527
}
1628
17-
let dialog
18-
let textareaEl
19-
let gradientText = ''
20-
let error = ''
21-
let valid = false
22-
let timer
23-
2429
function close() {
2530
dialog?.close()
2631
open = false
@@ -33,21 +38,32 @@
3338
gradientText = e.currentTarget.value
3439
scheduleValidate()
3540
}
41+
$: canImport = valid && gradientText.trim().length > 0
3642
3743
function scheduleValidate() {
3844
clearTimeout(timer)
39-
timer = setTimeout(validate, 150)
45+
timer = setTimeout(validate, 200)
4046
}
4147
4248
function validate() {
4349
try {
44-
const parsed = parseGradient(gradientText)
50+
parseGradient(gradientText)
4551
valid = true
4652
error = ''
47-
// auto-import and close upon success
53+
} catch (e) {
54+
valid = false
55+
error = e instanceof ParseError ? e.message : 'Invalid gradient'
56+
}
57+
}
58+
59+
function onImportClick() {
60+
if (!valid || !gradientText.trim()) return
61+
try {
62+
const parsed = parseGradient(gradientText)
4863
applyParsedToStores(parsed)
4964
close()
5065
} catch (e) {
66+
// Should be rare because button is disabled when invalid; keep safe
5167
valid = false
5268
error = e instanceof ParseError ? e.message : 'Invalid gradient'
5369
}
@@ -57,80 +73,110 @@
5773
</script>
5874
5975
{#if open}
60-
<dialog bind:this={dialog} class="import-dialog" on:close={close}>
61-
<div class="panel">
62-
<h2 class="title">Import CSS Gradient</h2>
63-
<div class="editor {valid ? 'ok' : (error ? 'bad' : '')}">
64-
<textarea
65-
bind:this={textareaEl}
66-
placeholder="Paste any valid CSS gradient..."
76+
<dialog
77+
bind:this={dialog}
78+
class="push-z"
79+
id="import-dialog"
80+
aria-labelledby={titleId}
81+
aria-describedby="import-error"
82+
on:close={close}
83+
>
84+
<section>
85+
<form on:submit|preventDefault={onImportClick} aria-labelledby={titleId}>
86+
<h2 class="title" id={titleId}>Import CSS Gradient</h2>
87+
<label class="sr-only" for={inputId}>CSS gradient string</label>
88+
<ImportEditor
89+
on:input={(e) => { gradientText = e.detail.value; scheduleValidate() }}
90+
{error}
91+
{valid}
6792
value={gradientText}
68-
on:input={onInput}
69-
spellcheck={false}
70-
aria-invalid={!valid}
71-
aria-describedby="import-error"
72-
autofocus
93+
inputId={inputId}
94+
bind:this={textareaEl}
7395
/>
74-
</div>
75-
{#if error}
76-
<p id="import-error" role="alert" class="error">{error}</p>
77-
{/if}
78-
<div class="actions">
79-
<button type="button" on:click={close}>Cancel</button>
80-
</div>
81-
</div>
96+
<ImportActions {canImport} primaryType="submit" on:cancel={close} on:import={onImportClick} />
97+
</form>
98+
</section>
8299
</dialog>
83100
{/if}
84101
85102
<style>
86-
.import-dialog::backdrop {
87-
background: color-mix(in oklab, black 60%, transparent);
103+
@media (prefers-reduced-motion: no-preference) {
104+
:global(body) {
105+
transition:
106+
scale .8s var(--ease-in-out-5),
107+
border-radius .8s var(--ease-in-out-5);
108+
109+
&:has(.push-z[open]) {
110+
scale: 95%;
111+
border-radius: var(--radius-3);
112+
overflow: hidden;
113+
}
114+
}
88115
}
89-
.import-dialog {
90-
border: none;
91-
outline: none;
116+
117+
dialog.push-z {
118+
--_duration: .5s;
119+
background: none;
120+
box-shadow: none;
92121
padding: 0;
93-
max-width: none;
94-
width: calc(100vw - (var(--size-5) * 2));
95-
margin-inline: var(--size-5);
96-
height: 100vh;
97-
background: transparent;
98-
}
99-
.panel {
100-
display: grid;
101-
grid-template-rows: auto 1fr auto;
102-
gap: var(--size-4);
103-
box-sizing: border-box;
104-
width: 100%;
105-
height: 100%;
106-
padding: var(--size-7);
107-
background: var(--surface-2, white);
108-
color: var(--text-1, black);
109-
border-radius: var(--radius-4);
110-
box-shadow: var(--shadow-4);
111-
}
112-
.editor {
113-
position: relative;
114-
}
115-
.title {
116-
font-size: var(--font-size-2);
117-
text-align: center;
118-
margin: 0;
119-
}
120-
.editor textarea {
121-
width: 100%;
122-
height: 40vh;
123-
resize: none;
124-
background: var(--surface-1, var(--gray-9));
125-
color: var(--text-1, black);
126-
border: 1px solid var(--surface-3);
127-
border-radius: var(--radius-3);
128-
padding: var(--size-3);
129-
font: 400 14px/1.6 var(--font-mono);
130-
white-space: pre-wrap;
122+
overflow: clip;
123+
transition:
124+
display var(--_duration) allow-discrete,
125+
overlay var(--_duration) allow-discrete;
126+
127+
&::backdrop {
128+
transition: opacity var(--_duration) var(--ease-4);
129+
opacity: 0;
130+
background-color: light-dark(#0003, #0008);
131+
cursor: zoom-out;
132+
}
133+
134+
& > section {
135+
> form {
136+
display: grid;
137+
gap: var(--size-4);
138+
}
139+
140+
@media (prefers-reduced-motion: reduce) {
141+
transition: opacity .7s var(--ease-2);
142+
opacity: 0;
143+
}
144+
@media (prefers-reduced-motion: no-preference) {
145+
transition: translate .7s var(--ease-elastic-in-out-2) .0s;
146+
translate: 0 100%;
147+
}
148+
}
149+
150+
&[open] {
151+
&, &::backdrop { opacity: 1; }
152+
153+
& > section {
154+
opacity: 1;
155+
translate: 0;
156+
@media (prefers-reduced-motion: no-preference) {
157+
transition-delay: var(--_duration);
158+
transition-timing-function: var(--ease-spring-2);
159+
}
160+
}
161+
}
162+
163+
@starting-style {
164+
&[open], &[open]::backdrop { opacity: 0; }
165+
&[open] > section { opacity: 0; translate: 0 100%; }
166+
}
167+
168+
section {
169+
inline-size: min(var(--size-content-2), 80vw);
170+
aspect-ratio: var(--ratio-landscape);
171+
border-radius: var(--radius-3);
172+
background: light-dark(white, var(--surface-2));
173+
padding: var(--size-5);
174+
175+
@media (orientation: portrait) { aspect-ratio: var(--ratio-portrait); }
176+
}
131177
}
132-
.editor.ok textarea { border-color: var(--green-6); }
133-
.editor.bad textarea { border-color: var(--red-6); }
134-
.actions { display: flex; justify-content: flex-end; gap: var(--size-2); }
178+
179+
.title { margin: 0; font-size: var(--font-size-2); }
180+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
135181
</style>
136182
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
import { createEventDispatcher } from 'svelte'
3+
export let canImport = false
4+
export let primaryType = 'button'
5+
const dispatch = createEventDispatcher()
6+
</script>
7+
8+
<div class="actions">
9+
<button type="reset" class="btn secondary" on:click={() => dispatch('cancel')}>Cancel</button>
10+
<button type={primaryType} class="btn primary" disabled={!canImport} aria-disabled={!canImport} on:click={() => canImport && dispatch('import')}>Import</button>
11+
</div>
12+
13+
<style>
14+
.actions { display: flex; justify-content: flex-end; gap: var(--size-2); }
15+
.btn { min-height: 40px; padding-inline: var(--size-3); border-radius: var(--radius-2); border: 1px solid var(--surface-3); }
16+
.btn.primary { background: var(--indigo-6); color: white; border-color: var(--indigo-6); }
17+
.btn.primary:disabled { background: var(--surface-3); color: var(--text-2); border-color: var(--surface-3); }
18+
</style>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script>
2+
import { createEventDispatcher } from 'svelte'
3+
export let value = ''
4+
export let valid = false
5+
export let error = ''
6+
export let inputId = 'import-gradient-input'
7+
export let describedBy = 'import-error'
8+
const dispatch = createEventDispatcher()
9+
let textareaEl
10+
export function focus() { textareaEl?.focus() }
11+
function handleInput(e) { dispatch('input', { value: e.currentTarget.value }) }
12+
</script>
13+
14+
<div class="editor {valid ? 'ok' : (error ? 'bad' : '')}">
15+
<textarea
16+
bind:this={textareaEl}
17+
id={inputId}
18+
placeholder="Paste any valid CSS gradient..."
19+
value={value}
20+
on:input={handleInput}
21+
spellcheck={false}
22+
aria-invalid={!valid}
23+
aria-describedby={describedBy}
24+
></textarea>
25+
</div>
26+
{#if error}
27+
<p id="import-error" aria-live="polite" role="alert" class="error">{error}</p>
28+
{/if}
29+
30+
<style>
31+
.editor { position: relative; }
32+
textarea {
33+
width: 100%;
34+
height: 40vh;
35+
resize: none;
36+
background: var(--surface-1, var(--gray-9));
37+
color: var(--text-1, black);
38+
border: 1px solid var(--surface-3);
39+
border-radius: var(--radius-3);
40+
padding: var(--size-3);
41+
font: 400 14px/1.6 var(--font-mono);
42+
white-space: pre-wrap;
43+
outline: none;
44+
}
45+
textarea:focus-visible { outline: 2px solid var(--indigo-6); outline-offset: 2px; }
46+
.ok textarea { border-color: var(--green-6); }
47+
.bad textarea { border-color: var(--red-6); }
48+
</style>

0 commit comments

Comments
 (0)