@studiocms/ui 0.1.0 → 0.3.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.
- package/package.json +2 -2
- package/src/components/Button.astro +302 -269
- package/src/components/Card.astro +27 -3
- package/src/components/Checkbox.astro +72 -3
- package/src/components/Divider.astro +8 -1
- package/src/components/Dropdown/Dropdown.astro +55 -2
- package/src/components/Dropdown/dropdown.ts +104 -17
- package/src/components/Footer.astro +21 -4
- package/src/components/Input.astro +27 -0
- package/src/components/Modal/Modal.astro +31 -1
- package/src/components/Modal/modal.ts +33 -0
- package/src/components/RadioGroup.astro +132 -8
- package/src/components/Row.astro +9 -0
- package/src/components/SearchSelect.astro +249 -197
- package/src/components/Select.astro +229 -105
- package/src/components/Sidebar/helpers.ts +46 -0
- package/src/components/Tabs/TabItem.astro +47 -0
- package/src/components/Tabs/Tabs.astro +376 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Textarea.astro +30 -0
- package/src/components/ThemeToggle.astro +6 -3
- package/src/components/Toast/Toaster.astro +140 -1
- package/src/components/Toggle.astro +77 -9
- package/src/components/User.astro +20 -2
- package/src/components/index.ts +1 -0
- package/src/components.ts +1 -0
- package/src/css/colors.css +8 -8
- package/src/css/resets.css +0 -1
- package/src/integration.ts +18 -0
- package/src/layouts/RootLayout.astro +0 -1
- package/src/utils/ThemeHelper.ts +8 -1
- package/src/utils/create-resolver.ts +30 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Icon } from '../../utils';
|
|
3
|
+
import type { StudioCMSColorway } from '../../utils/colors';
|
|
4
|
+
import { generateID } from '../../utils/generateID';
|
|
5
|
+
import type { HeroIconName } from '../../utils/iconType';
|
|
6
|
+
|
|
7
|
+
interface Tab {
|
|
8
|
+
icon?: HeroIconName;
|
|
9
|
+
label: string;
|
|
10
|
+
color: Exclude<StudioCMSColorway, 'default'>;
|
|
11
|
+
tabId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The props for the Tabs component.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
/**
|
|
19
|
+
* The sync key for the tabs. If provided, the active tab will be synced across all instances of the tabs with the same sync key.
|
|
20
|
+
* Additionally, the active tab will be stored session- or local storage depending on the `storage` prop.
|
|
21
|
+
*/
|
|
22
|
+
syncKey?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The storage type for the tabs. Defaults to `session`.
|
|
25
|
+
*/
|
|
26
|
+
storage?: 'session' | 'persistent';
|
|
27
|
+
/**
|
|
28
|
+
* The variant of the tabs. Defaults to `default`.
|
|
29
|
+
*/
|
|
30
|
+
variant?: 'default' | 'starlight';
|
|
31
|
+
/**
|
|
32
|
+
* The alignment of the tabs. Defaults to `left`.
|
|
33
|
+
*/
|
|
34
|
+
align?: 'left' | 'center' | 'right';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const extractTabInfoWithRegex = (input: string) => {
|
|
38
|
+
const tabItemRegex = /<sui-tab-item([^>]*)>/g;
|
|
39
|
+
|
|
40
|
+
const attributeRegex = /data-([\w-]+)="([^"]*)"/g;
|
|
41
|
+
|
|
42
|
+
const tabs: Tab[] = [];
|
|
43
|
+
let tabMatch: RegExpExecArray | null;
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: Nop
|
|
46
|
+
while ((tabMatch = tabItemRegex.exec(input)) !== null) {
|
|
47
|
+
let attributes: { [key: string]: string } = {};
|
|
48
|
+
|
|
49
|
+
let attributeMatch: RegExpExecArray | null;
|
|
50
|
+
|
|
51
|
+
if (!tabMatch[1]) continue;
|
|
52
|
+
|
|
53
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: Nop
|
|
54
|
+
while ((attributeMatch = attributeRegex.exec(tabMatch[1])) !== null) {
|
|
55
|
+
if (!attributeMatch[1] || !attributeMatch[2]) continue;
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
attributeMatch[1] === 'icon' ||
|
|
59
|
+
attributeMatch[1] === 'label' ||
|
|
60
|
+
attributeMatch[1] === 'color'
|
|
61
|
+
) {
|
|
62
|
+
attributes[attributeMatch[1]] = attributeMatch[2];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (attributeMatch[1] === 'tab-id') {
|
|
66
|
+
attributes.tabId = attributeMatch[2];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
tabs.push(attributes as unknown as Tab);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return tabs;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const markTabAsActive = (tabId: string, html: string): string => {
|
|
77
|
+
if (!tabId) return html;
|
|
78
|
+
|
|
79
|
+
const updatedHtml = html.replace(
|
|
80
|
+
new RegExp(`(<sui-tab-item[^>]*data-tab-id="${tabId}"[^>]*class=")([^"]*)(")`, 'g'),
|
|
81
|
+
'$1$2 active$3'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return updatedHtml;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const uniqueId = generateID('sui-tabs-container');
|
|
88
|
+
|
|
89
|
+
const {
|
|
90
|
+
syncKey: originalSyncKey,
|
|
91
|
+
storage = 'session',
|
|
92
|
+
variant = 'default',
|
|
93
|
+
align = 'left',
|
|
94
|
+
} = Astro.props;
|
|
95
|
+
|
|
96
|
+
const syncKey = originalSyncKey ? `sui-tabs-${originalSyncKey}` : undefined;
|
|
97
|
+
|
|
98
|
+
const tabContents = await Astro.slots.render('default');
|
|
99
|
+
const tabs = extractTabInfoWithRegex(tabContents);
|
|
100
|
+
const finalizedTabContents = markTabAsActive(tabs[0]?.tabId || '', tabContents);
|
|
101
|
+
const containerId = generateID('sui-tabs-container');
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
<div
|
|
105
|
+
class="sui-tabs-container"
|
|
106
|
+
id={containerId}
|
|
107
|
+
data-sync-key={syncKey}
|
|
108
|
+
data-unique-id={uniqueId}
|
|
109
|
+
data-storage-strategy={storage}
|
|
110
|
+
class:list={[variant, align]}
|
|
111
|
+
>
|
|
112
|
+
<div class="sui-tabs-list" role="tablist">
|
|
113
|
+
{tabs.map((tab, i) => (
|
|
114
|
+
<button
|
|
115
|
+
role="tab"
|
|
116
|
+
class="sui-tab-header"
|
|
117
|
+
id={syncKey ? `${syncKey}-${i}` : undefined}
|
|
118
|
+
tabindex={i === 0 ? 0 : -1}
|
|
119
|
+
data-tab-child={tab.tabId}
|
|
120
|
+
class:list={[i === 0 && "active", tab.color, syncKey && `${syncKey}:${i}`]}
|
|
121
|
+
>
|
|
122
|
+
{tab.icon && (
|
|
123
|
+
<Icon name={tab.icon} width={24} height={24} />
|
|
124
|
+
)}
|
|
125
|
+
<span>{tab.label}</span>
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
<div class="sui-tabs-content">
|
|
130
|
+
<Fragment set:html={finalizedTabContents} />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<script>
|
|
134
|
+
const tabContainers = document.querySelectorAll<HTMLDivElement>('.sui-tabs-container');
|
|
135
|
+
|
|
136
|
+
for (const tabContainer of tabContainers) {
|
|
137
|
+
const storage = tabContainer.dataset.storageStrategy!;
|
|
138
|
+
const syncKey = tabContainer.dataset.syncKey!;
|
|
139
|
+
|
|
140
|
+
let storageLayer = storage === 'session' ? sessionStorage : localStorage;
|
|
141
|
+
|
|
142
|
+
const constructCustomEvent = (tabIndex: number, uniqueId: string) => {
|
|
143
|
+
return new CustomEvent(`sui-tab-switch:${syncKey}`, {
|
|
144
|
+
detail: {
|
|
145
|
+
tabIndex,
|
|
146
|
+
uniqueId
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const switchTab = (target: HTMLElement, container: HTMLElement, originatedFromSync = false) => {
|
|
152
|
+
const activeChildren = container.querySelectorAll<HTMLElement>('.active');
|
|
153
|
+
|
|
154
|
+
for (const child of activeChildren) {
|
|
155
|
+
child.tabIndex = -1;
|
|
156
|
+
child.classList.remove('active');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const newActiveTab = target as HTMLElement;
|
|
160
|
+
newActiveTab.classList.add('active');
|
|
161
|
+
newActiveTab.tabIndex = 0;
|
|
162
|
+
|
|
163
|
+
const newActiveTabContentId = newActiveTab.dataset.tabChild;
|
|
164
|
+
const newActiveTabContent = container.querySelector<HTMLElement>(`sui-tab-item[data-tab-id="${newActiveTabContentId}"]`)!;
|
|
165
|
+
|
|
166
|
+
newActiveTabContent.classList.add('active');
|
|
167
|
+
|
|
168
|
+
if (syncKey && !originatedFromSync) {
|
|
169
|
+
const tabIndex = Array.prototype.indexOf.call(newActiveTab.parentElement!.children, newActiveTab);
|
|
170
|
+
storageLayer.setItem(syncKey, tabIndex.toString());
|
|
171
|
+
|
|
172
|
+
document.dispatchEvent(constructCustomEvent(tabIndex, container.dataset.uniqueId!));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tabHeaders = tabContainer.querySelectorAll<HTMLElement>('.sui-tab-header');
|
|
178
|
+
|
|
179
|
+
for (const tab of tabHeaders) {
|
|
180
|
+
tab.addEventListener('click', (e) => switchTab(e.target as HTMLElement, tabContainer));
|
|
181
|
+
|
|
182
|
+
tab.addEventListener('keydown', (e) => {
|
|
183
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
184
|
+
const activeTabIndex = Array.prototype.indexOf.call(tab.parentElement!.children, tab);
|
|
185
|
+
const nextTabIndex = e.key === 'ArrowLeft' ? activeTabIndex - 1 : activeTabIndex + 1;
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if (nextTabIndex >= 0 && nextTabIndex < tab.parentElement!.children.length) {
|
|
189
|
+
tab.tabIndex = -1;
|
|
190
|
+
|
|
191
|
+
const nextTab = tab.parentElement!.children[nextTabIndex]! as HTMLElement;
|
|
192
|
+
|
|
193
|
+
nextTab.tabIndex = 0;
|
|
194
|
+
nextTab.click();
|
|
195
|
+
nextTab.focus();
|
|
196
|
+
} else if (nextTabIndex < 0) {
|
|
197
|
+
tab.tabIndex = -1;
|
|
198
|
+
|
|
199
|
+
const lastTab = tab.parentElement!.children[tab.parentElement!.children.length - 1] as HTMLElement;
|
|
200
|
+
|
|
201
|
+
lastTab.tabIndex = 0;
|
|
202
|
+
lastTab.click();
|
|
203
|
+
lastTab.focus();
|
|
204
|
+
} else {
|
|
205
|
+
tab.tabIndex = -1;
|
|
206
|
+
|
|
207
|
+
const firstTab = tab.parentElement!.children[0] as HTMLElement;
|
|
208
|
+
|
|
209
|
+
firstTab.tabIndex = 0;
|
|
210
|
+
firstTab.click();
|
|
211
|
+
firstTab.focus();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (syncKey) {
|
|
218
|
+
// Retrieve the sync key value from localstorage, set the tab.
|
|
219
|
+
const activeTabIndex = storageLayer.getItem(syncKey);
|
|
220
|
+
|
|
221
|
+
if (activeTabIndex) {
|
|
222
|
+
const activeTab = tabContainer.querySelector<HTMLElement>(`#${syncKey}-${activeTabIndex}`);
|
|
223
|
+
|
|
224
|
+
if (activeTab) {
|
|
225
|
+
activeTab.click();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
document.addEventListener(`sui-tab-switch:${syncKey}`, (e) => {
|
|
230
|
+
const event = e as CustomEvent<{ tabIndex: number, uniqueId: string }>;
|
|
231
|
+
const { tabIndex, uniqueId } = event.detail;
|
|
232
|
+
|
|
233
|
+
if (uniqueId === tabContainer.dataset.uniqueId) return;
|
|
234
|
+
|
|
235
|
+
const newTab = tabContainer.querySelector<HTMLElement>(`#${syncKey}-${tabIndex}`)!;
|
|
236
|
+
|
|
237
|
+
switchTab(newTab, tabContainer, true);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
</script>
|
|
242
|
+
<style>
|
|
243
|
+
.sui-tabs-container {
|
|
244
|
+
width: 100%;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.sui-tabs-list {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: row;
|
|
250
|
+
gap: 1rem;
|
|
251
|
+
align-items: center;
|
|
252
|
+
width: 100%;
|
|
253
|
+
overflow-x: auto;
|
|
254
|
+
overflow-y: visible;
|
|
255
|
+
position: relative;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.default .sui-tabs-list {
|
|
259
|
+
margin-top: -4px;
|
|
260
|
+
margin-bottom: calc(2rem - 4px);
|
|
261
|
+
padding: 4px 4px;
|
|
262
|
+
margin-left: -4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.center .sui-tabs-list {
|
|
266
|
+
justify-content: center;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.right .sui-tabs-list {
|
|
270
|
+
justify-content: flex-end;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.sui-tab-header {
|
|
274
|
+
margin-top: 0 !important;
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: row;
|
|
277
|
+
gap: .5rem;
|
|
278
|
+
cursor: pointer;
|
|
279
|
+
position: relative;
|
|
280
|
+
min-width: fit-content;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.default .sui-tab-header {
|
|
284
|
+
border-radius: 0.5rem;
|
|
285
|
+
height: 40px;
|
|
286
|
+
padding: 0.5rem 0.75rem;
|
|
287
|
+
transition: all .15s ease;
|
|
288
|
+
font-size: 0.875em;
|
|
289
|
+
outline: 2px solid transparent;
|
|
290
|
+
outline-offset: 2px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.sui-tab-header * {
|
|
294
|
+
pointer-events: none;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.default .sui-tab-header:focus-visible {
|
|
298
|
+
outline: 2px solid hsl(var(--text-normal));
|
|
299
|
+
outline-offset: 2px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.default .sui-tab-header:hover {
|
|
303
|
+
background-color: hsla(var(--default-flat-active)) !important;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.default .sui-tab-header.active {
|
|
307
|
+
background-color: hsla(var(--primary-flat-active)) !important;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.default .sui-tab-header.success.active {
|
|
311
|
+
background-color: hsla(var(--success-flat-active)) !important;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.default .sui-tab-header.warning.active {
|
|
315
|
+
background-color: hsla(var(--warning-flat-active)) !important;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.default .sui-tab-header.danger.active {
|
|
319
|
+
background-color: hsla(var(--danger-flat-active)) !important;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.starlight .sui-tabs-list {
|
|
323
|
+
margin-bottom: 1rem;
|
|
324
|
+
gap: 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.starlight .sui-tabs-list::before {
|
|
328
|
+
content: "";
|
|
329
|
+
position: absolute;
|
|
330
|
+
bottom: 0;
|
|
331
|
+
left: 0;
|
|
332
|
+
width: 100%;
|
|
333
|
+
height: 2px;
|
|
334
|
+
background-color: hsl(var(--border));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.starlight .sui-tab-header {
|
|
338
|
+
padding: 0.25rem 1.25rem;
|
|
339
|
+
color: hsl(var(--text-muted));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.starlight .sui-tab-header.active {
|
|
343
|
+
font-weight: 600;
|
|
344
|
+
color: hsl(var(--text-normal));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.starlight .sui-tab-header.active::after {
|
|
348
|
+
content: "";
|
|
349
|
+
width: 100%;
|
|
350
|
+
height: 2px;
|
|
351
|
+
background-color: hsl(var(--primary-base));
|
|
352
|
+
position: absolute;
|
|
353
|
+
bottom: 0;
|
|
354
|
+
left: 0;
|
|
355
|
+
z-index: 15;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.starlight .sui-tab-header:focus-visible::after {
|
|
359
|
+
height: calc(100% - 2px);
|
|
360
|
+
width: calc(100% - 2px);
|
|
361
|
+
bottom: 1px;
|
|
362
|
+
left: 1px;
|
|
363
|
+
border: 2px solid hsl(var(--primary-base));
|
|
364
|
+
background-color: transparent;
|
|
365
|
+
outline: 1px solid hsl(var(--text-normal));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.default .sui-tab-header.active {
|
|
369
|
+
background-color: hsla(var(--primary-flat-active)) !important;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.sui-tabs-content {
|
|
373
|
+
width: 100%;
|
|
374
|
+
margin: 0 !important;
|
|
375
|
+
}
|
|
376
|
+
</style>
|
|
@@ -2,15 +2,45 @@
|
|
|
2
2
|
import type { HTMLAttributes } from 'astro/types';
|
|
3
3
|
import { generateID } from '../utils/generateID';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Props for the textarea component
|
|
7
|
+
*/
|
|
5
8
|
interface Props extends HTMLAttributes<'textarea'> {
|
|
9
|
+
/**
|
|
10
|
+
* The label of the textarea.
|
|
11
|
+
*/
|
|
6
12
|
label?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The placeholder of the textarea.
|
|
15
|
+
*/
|
|
7
16
|
placeholder?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the textarea is required. Defaults to `false`.
|
|
19
|
+
*/
|
|
8
20
|
isRequired?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the textarea should take up the full width of its container. Defaults to `false`.
|
|
23
|
+
*/
|
|
9
24
|
fullWidth?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the textarea should take up the full height of its container. Defaults to `false`.
|
|
27
|
+
*/
|
|
10
28
|
fullHeight?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the textarea should be resizable. Defaults to `false`.
|
|
31
|
+
*/
|
|
11
32
|
resize?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The name attribute for the textarea. Useful for form submission.
|
|
35
|
+
*/
|
|
12
36
|
name?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether the textarea is disabled. Defaults to `false`.
|
|
39
|
+
*/
|
|
13
40
|
disabled?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The default value of the textarea.
|
|
43
|
+
*/
|
|
14
44
|
defaultValue?: string;
|
|
15
45
|
}
|
|
16
46
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
3
|
-
import Button from './Button.astro';
|
|
2
|
+
import Button, { type Props as ButtonProps } from './Button.astro';
|
|
4
3
|
|
|
5
|
-
interface Props extends
|
|
4
|
+
interface Props extends ButtonProps {}
|
|
6
5
|
|
|
7
6
|
const props = Astro.props;
|
|
8
7
|
---
|
|
@@ -26,6 +25,10 @@ const props = Astro.props;
|
|
|
26
25
|
</script>
|
|
27
26
|
|
|
28
27
|
<style is:global>
|
|
28
|
+
#sui-theme-toggle, #sui-theme-toggle * {
|
|
29
|
+
color: hsl(var(--text-normal));
|
|
30
|
+
}
|
|
31
|
+
|
|
29
32
|
#sui-theme-toggle #dark-content, #sui-theme-toggle #light-content {
|
|
30
33
|
display: none;
|
|
31
34
|
width: fit-content;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
+
/**
|
|
3
|
+
* Props for the Toast component.
|
|
4
|
+
*/
|
|
2
5
|
interface Props {
|
|
6
|
+
/**
|
|
7
|
+
* The position of the toaster. All toasts will originate from this position.
|
|
8
|
+
*/
|
|
3
9
|
position?:
|
|
4
10
|
| 'top-left'
|
|
5
11
|
| 'top-right'
|
|
@@ -7,9 +13,21 @@ interface Props {
|
|
|
7
13
|
| 'bottom-left'
|
|
8
14
|
| 'bottom-right'
|
|
9
15
|
| 'bottom-center';
|
|
16
|
+
/**
|
|
17
|
+
* The duration of the toast in milliseconds. Defaults to 4000 (4 seconds).
|
|
18
|
+
*/
|
|
10
19
|
duration?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the toast has a close button. Defaults to false.
|
|
22
|
+
*/
|
|
11
23
|
closeButton?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The offset of the toaster from the edge of the screen in pixels. Defaults to 32.
|
|
26
|
+
*/
|
|
12
27
|
offset?: number;
|
|
28
|
+
/**
|
|
29
|
+
* The gap between toasts in pixels. Defaults to 8.
|
|
30
|
+
*/
|
|
13
31
|
gap?: number;
|
|
14
32
|
}
|
|
15
33
|
|
|
@@ -53,6 +71,65 @@ const {
|
|
|
53
71
|
|
|
54
72
|
let activeToasts: string[] = [];
|
|
55
73
|
|
|
74
|
+
let lastActiveElement: HTMLElement | null = null;
|
|
75
|
+
|
|
76
|
+
const revertFocusBackToLastActiveElement = () => {
|
|
77
|
+
if (lastActiveElement) {
|
|
78
|
+
lastActiveElement.focus();
|
|
79
|
+
lastActiveElement = null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Callback wrapper that allows for pausing, continuing and clearing a timer. Based on https://stackoverflow.com/a/20745721.
|
|
85
|
+
* @param callback The callback to be called.
|
|
86
|
+
* @param delay The delay in milliseconds.
|
|
87
|
+
*/
|
|
88
|
+
class Timer {
|
|
89
|
+
private id: NodeJS.Timeout | null;
|
|
90
|
+
private started: Date | null;
|
|
91
|
+
private remaining: number;
|
|
92
|
+
private running: boolean;
|
|
93
|
+
private callback: () => any;
|
|
94
|
+
|
|
95
|
+
constructor(callback: () => any, delay: number) {
|
|
96
|
+
this.id = null;
|
|
97
|
+
this.started = null;
|
|
98
|
+
this.remaining = delay;
|
|
99
|
+
this.running = false;
|
|
100
|
+
this.callback = callback;
|
|
101
|
+
|
|
102
|
+
this.start();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
start = () => {
|
|
106
|
+
this.running = true;
|
|
107
|
+
this.started = new Date();
|
|
108
|
+
this.id = setTimeout(this.callback, this.remaining);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
pause = () => {
|
|
112
|
+
if (!this.id || !this.started || !this.running) return;
|
|
113
|
+
|
|
114
|
+
this.running = false;
|
|
115
|
+
clearTimeout(this.id);
|
|
116
|
+
this.remaining -= new Date().getTime() - this.started.getTime();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
getTimeLeft = () => {
|
|
120
|
+
if (this.running) {
|
|
121
|
+
this.pause();
|
|
122
|
+
this.start();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return this.remaining;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
getStateRunning = () => {
|
|
129
|
+
return this.running;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
56
133
|
function removeToast(toastID: string) {
|
|
57
134
|
const toastEl = document.getElementById(toastID);
|
|
58
135
|
|
|
@@ -70,7 +147,11 @@ const {
|
|
|
70
147
|
|
|
71
148
|
const toastContainer = document.createElement('div');
|
|
72
149
|
const toastID = generateID('toast');
|
|
150
|
+
toastContainer.tabIndex = 0;
|
|
151
|
+
toastContainer.ariaLive = 'polite';
|
|
152
|
+
toastContainer.role = 'alert';
|
|
73
153
|
toastContainer.id = toastID;
|
|
154
|
+
toastContainer.ariaLabel = `${props.title} (F8)`;
|
|
74
155
|
toastContainer.classList.add('sui-toast-container', props.type, `${props.closeButton || props.persistent && "closeable"}`, `${props.persistent && 'persistent'}`);
|
|
75
156
|
|
|
76
157
|
const toastHeader = document.createElement('div');
|
|
@@ -106,6 +187,8 @@ const {
|
|
|
106
187
|
closeIconContainer.classList.add('close-icon-container');
|
|
107
188
|
closeIconContainer.addEventListener('click', () => removeToast(toastID));
|
|
108
189
|
closeIconContainer.innerHTML = getIconString('x-mark', 'close-icon', 24, 24);
|
|
190
|
+
closeIconContainer.tabIndex = 0;
|
|
191
|
+
closeIconContainer.ariaLabel = 'Close toast';
|
|
109
192
|
|
|
110
193
|
toastHeader.appendChild(closeIconContainer);
|
|
111
194
|
}
|
|
@@ -133,11 +216,43 @@ const {
|
|
|
133
216
|
activeToasts.push(toastID);
|
|
134
217
|
|
|
135
218
|
if (!props.persistent) {
|
|
136
|
-
|
|
219
|
+
let timer = new Timer(
|
|
137
220
|
() => removeToast(toastID),
|
|
138
221
|
props.duration || (toastParent.dataset.duration ? parseInt(toastParent.dataset.duration) : 4000)
|
|
139
222
|
);
|
|
223
|
+
|
|
224
|
+
const timerPauseWrapper = () => {
|
|
225
|
+
toastContainer.classList.add('paused');
|
|
226
|
+
timer.pause();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const timerStartWrapper = () => {
|
|
230
|
+
toastContainer.classList.remove('paused');
|
|
231
|
+
timer.start();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
toastContainer.addEventListener('mouseenter', timerPauseWrapper);
|
|
235
|
+
toastContainer.addEventListener('focusin', timerPauseWrapper);
|
|
236
|
+
|
|
237
|
+
toastContainer.addEventListener('mouseleave', timerStartWrapper);
|
|
238
|
+
toastContainer.addEventListener('focusout', () => {
|
|
239
|
+
let focusedOrHasFocused = toastContainer.matches(':focus-within');
|
|
240
|
+
|
|
241
|
+
if (!focusedOrHasFocused) {
|
|
242
|
+
revertFocusBackToLastActiveElement();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
timerStartWrapper();
|
|
246
|
+
});
|
|
140
247
|
}
|
|
248
|
+
|
|
249
|
+
toastContainer.addEventListener('keydown', (e) => {
|
|
250
|
+
if (e.key === 'Escape') {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
removeToast(toastID);
|
|
253
|
+
revertFocusBackToLastActiveElement();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
141
256
|
}
|
|
142
257
|
|
|
143
258
|
document.addEventListener('createtoast', (e) => {
|
|
@@ -147,6 +262,21 @@ const {
|
|
|
147
262
|
|
|
148
263
|
createToast(event.detail);
|
|
149
264
|
});
|
|
265
|
+
|
|
266
|
+
window.addEventListener('keydown', (e) => {
|
|
267
|
+
if (e.key === 'F8') {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
|
|
270
|
+
const oldestToast = activeToasts[0];
|
|
271
|
+
|
|
272
|
+
if (oldestToast) {
|
|
273
|
+
lastActiveElement = document.activeElement as HTMLElement;
|
|
274
|
+
|
|
275
|
+
const toastEl = document.getElementById(oldestToast);
|
|
276
|
+
if (toastEl) toastEl?.focus();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
150
280
|
</script>
|
|
151
281
|
<style>
|
|
152
282
|
#sui-toaster {
|
|
@@ -235,6 +365,10 @@ const {
|
|
|
235
365
|
animation: toast-progress forwards linear;
|
|
236
366
|
}
|
|
237
367
|
|
|
368
|
+
.sui-toast-container.paused .sui-toast-progress-bar {
|
|
369
|
+
animation-play-state: paused;
|
|
370
|
+
}
|
|
371
|
+
|
|
238
372
|
.sui-toast-container.success .sui-toast-progress-bar {
|
|
239
373
|
background-color: hsl(var(--success-base));
|
|
240
374
|
}
|
|
@@ -262,6 +396,11 @@ const {
|
|
|
262
396
|
background-color: hsl(var(--default-base));
|
|
263
397
|
}
|
|
264
398
|
|
|
399
|
+
.close-icon-container:focus-visible {
|
|
400
|
+
outline: 2px solid hsl(var(--text-normal));
|
|
401
|
+
outline-offset: 2px;
|
|
402
|
+
}
|
|
403
|
+
|
|
265
404
|
.sui-toast-container.closing {
|
|
266
405
|
animation: toast-closing .25s ease forwards;
|
|
267
406
|
}
|