|
2 | 2 | import { onMount } from 'svelte' |
3 | 3 | import { parseGradient, ParseError } from '../lib/parseGradient' |
4 | 4 | import { applyParsedToStores } from '../lib/importGradient' |
| 5 | + import ImportEditor from './import/ImportEditor.svelte' |
| 6 | + import ImportActions from './import/ImportActions.svelte' |
5 | 7 |
|
6 | 8 | 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 | +
|
7 | 20 | export function show() { |
8 | 21 | open = true |
9 | | - // open dialog on next tick |
10 | 22 | queueMicrotask(() => { |
11 | 23 | 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) |
14 | 26 | }) |
15 | 27 | } |
16 | 28 |
|
17 | | - let dialog |
18 | | - let textareaEl |
19 | | - let gradientText = '' |
20 | | - let error = '' |
21 | | - let valid = false |
22 | | - let timer |
23 | | -
|
24 | 29 | function close() { |
25 | 30 | dialog?.close() |
26 | 31 | open = false |
|
33 | 38 | gradientText = e.currentTarget.value |
34 | 39 | scheduleValidate() |
35 | 40 | } |
| 41 | + $: canImport = valid && gradientText.trim().length > 0 |
36 | 42 |
|
37 | 43 | function scheduleValidate() { |
38 | 44 | clearTimeout(timer) |
39 | | - timer = setTimeout(validate, 150) |
| 45 | + timer = setTimeout(validate, 200) |
40 | 46 | } |
41 | 47 |
|
42 | 48 | function validate() { |
43 | 49 | try { |
44 | | - const parsed = parseGradient(gradientText) |
| 50 | + parseGradient(gradientText) |
45 | 51 | valid = true |
46 | 52 | 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) |
48 | 63 | applyParsedToStores(parsed) |
49 | 64 | close() |
50 | 65 | } catch (e) { |
| 66 | + // Should be rare because button is disabled when invalid; keep safe |
51 | 67 | valid = false |
52 | 68 | error = e instanceof ParseError ? e.message : 'Invalid gradient' |
53 | 69 | } |
|
57 | 73 | </script> |
58 | 74 |
|
59 | 75 | {#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} |
67 | 92 | 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} |
73 | 95 | /> |
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> |
82 | 99 | </dialog> |
83 | 100 | {/if} |
84 | 101 |
|
85 | 102 | <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 | + } |
88 | 115 | } |
89 | | - .import-dialog { |
90 | | - border: none; |
91 | | - outline: none; |
| 116 | +
|
| 117 | + dialog.push-z { |
| 118 | + --_duration: .5s; |
| 119 | + background: none; |
| 120 | + box-shadow: none; |
92 | 121 | 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 | + } |
131 | 177 | } |
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; } |
135 | 181 | </style> |
136 | 182 |
|
0 commit comments