浏览代码

Create notifier

Lee Morgan 4 天之前
父节点
当前提交
c099d6c819
共有 5 个文件被更改,包括 191 次插入0 次删除
  1. 1 0
      AGENTS.md
  2. 122 0
      src/lib/components/Notifier.svelte
  3. 64 0
      src/lib/notifier.svelte.js
  4. 2 0
      src/routes/+layout.svelte
  5. 2 0
      src/routes/register/+page.svelte

+ 1 - 0
AGENTS.md

@@ -105,3 +105,4 @@ CSS should be included in the svelte file that it applies to except in cases of
 
 - Never run the server, user will start it manually
 - Always ensure that UI works well on desktop, mobile, and everything in between
+- Always access for permission to install packages from NPM or elsewhere

+ 122 - 0
src/lib/components/Notifier.svelte

@@ -0,0 +1,122 @@
+<script>
+	import { dismiss, notices } from '$lib/notifier.svelte.js';
+</script>
+
+{#if notices.length}
+	<div class="notifier" aria-live="polite" aria-relevant="additions">
+		{#each notices as notice (notice.id)}
+			<div class="toast toast-{notice.kind}" role="status">
+				<p class="message">{notice.message}</p>
+				<button
+					type="button"
+					class="close"
+					aria-label="Dismiss"
+					onclick={() => dismiss(notice.id)}
+				>
+					×
+				</button>
+			</div>
+		{/each}
+	</div>
+{/if}
+
+<style>
+	.notifier {
+		position: fixed;
+		z-index: 1000;
+		top: 16px;
+		left: 50%;
+		transform: translateX(-50%);
+		width: min(360px, calc(100vw - 24px));
+		display: grid;
+		gap: 8px;
+		pointer-events: none;
+	}
+
+	.toast {
+		pointer-events: auto;
+		display: grid;
+		grid-template-columns: 1fr auto;
+		align-items: start;
+		gap: 10px;
+		padding: 12px 12px 12px 14px;
+		border-radius: var(--radius);
+		border: 1px solid var(--border);
+		background: var(--surface-2);
+		box-shadow: 0 12px 32px color-mix(in srgb, black 45%, transparent);
+		animation: toast-in 160ms ease-out;
+	}
+
+	.toast-ok {
+		border-color: color-mix(in srgb, var(--ok) 45%, var(--border));
+		background: color-mix(in srgb, var(--ok) 12%, var(--surface-2));
+	}
+
+	.toast-fail {
+		border-color: color-mix(in srgb, var(--fail) 45%, var(--border));
+		background: color-mix(in srgb, var(--fail) 12%, var(--surface-2));
+	}
+
+	.toast-warn {
+		border-color: color-mix(in srgb, var(--warn) 45%, var(--border));
+		background: color-mix(in srgb, var(--warn) 12%, var(--surface-2));
+	}
+
+	.toast-info {
+		border-color: color-mix(in srgb, var(--ember) 40%, var(--border));
+		background: color-mix(in srgb, var(--ember) 10%, var(--surface-2));
+	}
+
+	.message {
+		margin: 0;
+		font-size: 14px;
+		font-weight: 650;
+		line-height: 1.35;
+		color: var(--bone);
+	}
+
+	.toast-ok .message {
+		color: var(--ok);
+	}
+
+	.toast-fail .message {
+		color: var(--fail);
+	}
+
+	.toast-warn .message {
+		color: var(--warn);
+	}
+
+	.toast-info .message {
+		color: var(--heat);
+	}
+
+	.close {
+		margin: -4px -4px 0 0;
+		padding: 0 6px;
+		border: 0;
+		border-radius: 6px;
+		background: transparent;
+		color: var(--text-muted);
+		font-size: 18px;
+		line-height: 1.2;
+		cursor: pointer;
+	}
+
+	.close:hover,
+	.close:focus-visible {
+		color: var(--bone);
+		background: var(--plate);
+	}
+
+	@keyframes toast-in {
+		from {
+			opacity: 0;
+			transform: translateY(-6px);
+		}
+		to {
+			opacity: 1;
+			transform: translateY(0);
+		}
+	}
+</style>

+ 64 - 0
src/lib/notifier.svelte.js

@@ -0,0 +1,64 @@
+/** @typedef {'ok' | 'fail' | 'warn' | 'info'} NoticeKind */
+
+/**
+ * @typedef {object} Notice
+ * @property {number} id
+ * @property {NoticeKind} kind
+ * @property {string} message
+ * @property {ReturnType<typeof setTimeout>} [timer]
+ */
+
+const DEFAULT_MS = 4000;
+
+let nextId = 0;
+
+/** @type {Notice[]} */
+export const notices = $state([]);
+
+/**
+ * @param {number} id
+ */
+export function dismiss(id) {
+	const index = notices.findIndex((notice) => notice.id === id);
+	if (index === -1) return;
+
+	const [notice] = notices.splice(index, 1);
+	if (notice?.timer) clearTimeout(notice.timer);
+}
+
+/**
+ * @param {string} message
+ * @param {NoticeKind} [kind]
+ * @param {{ duration?: number }} [options]
+ * @returns {number}
+ */
+export function notify(message, kind = 'info', options = {}) {
+	const text = String(message ?? '').trim();
+	if (!text) return -1;
+
+	const id = ++nextId;
+	const duration = options.duration ?? DEFAULT_MS;
+
+	/** @type {Notice} */
+	const notice = { id, kind, message: text };
+	notices.push(notice);
+
+	if (duration > 0) {
+		notice.timer = setTimeout(() => dismiss(id), duration);
+	}
+
+	return id;
+}
+
+export const notifier = {
+	/** @param {string} message @param {{ duration?: number }} [options] */
+	info: (message, options) => notify(message, 'info', options),
+	/** @param {string} message @param {{ duration?: number }} [options] */
+	ok: (message, options) => notify(message, 'ok', options),
+	/** @param {string} message @param {{ duration?: number }} [options] */
+	warn: (message, options) => notify(message, 'warn', options),
+	/** @param {string} message @param {{ duration?: number }} [options] */
+	fail: (message, options) => notify(message, 'fail', options),
+	dismiss,
+	notify
+};

+ 2 - 0
src/routes/+layout.svelte

@@ -1,5 +1,6 @@
 <script>
 	import favicon from '$lib/images/logo_white.svg';
+	import Notifier from '$lib/components/Notifier.svelte';
 	import '$lib/torus.css';
 
 	let { children } = $props();
@@ -11,6 +12,7 @@
 </svelte:head>
 
 {@render children()}
+<Notifier />
 
 <style>
 	:global(*),

+ 2 - 0
src/routes/register/+page.svelte

@@ -1,5 +1,6 @@
 <script>
 	import logoMark from '$lib/images/logo_white.svg';
+	import { notifier } from '$lib/notifier.svelte.js';
 
 	let name = $state('');
 	let email = $state('');
@@ -8,6 +9,7 @@
 
 	function handleSubmit(event) {
 		event.preventDefault();
+		notifier.info('Registration submitted (test)');
 	}
 </script>