Merge pull request #67 from killer-cf/feat-style

Feat style
This commit is contained in:
Kilder Costa
2025-12-15 09:27:12 -03:00
committed by GitHub
6 changed files with 676 additions and 3 deletions

View File

@@ -2,6 +2,8 @@
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
type HeaderProps = {
left?: Snippet;
@@ -9,6 +11,43 @@
};
const { left, right }: HeaderProps = $props();
let themeSelectEl: HTMLSelectElement | null = null;
function safeGetThemeLS(): string | null {
try {
const t = localStorage.getItem('theme');
return t && t.trim() ? t : null;
} catch {
return null;
}
}
onMount(() => {
const persisted = safeGetThemeLS();
if (persisted) {
// Sincroniza UI + HTML com o valor persistido (evita select ficar "aqua" indevido)
if (themeSelectEl && themeSelectEl.value !== persisted) {
themeSelectEl.value = persisted;
}
aplicarTemaDaisyUI(persisted);
}
});
function onThemeChange(e: Event) {
const nextValue = (e.currentTarget as HTMLSelectElement | null)?.value ?? null;
// Se o theme-change não atualizar (caso comum após login/logout),
// garantimos aqui a persistência + aplicação imediata.
if (nextValue) {
try {
localStorage.setItem('theme', nextValue);
} catch {
// ignore
}
aplicarTemaDaisyUI(nextValue);
}
}
</script>
<header
@@ -36,9 +75,11 @@
<div class="flex items-center gap-2">
<select
bind:this={themeSelectEl}
class="select select-sm bg-base-100 border-base-300 w-40"
aria-label="Selecionar tema"
data-choose-theme
onchange={onThemeChange}
>
<option value="aqua">Aqua</option>
<option value="sgse-blue">Azul</option>

View File

@@ -40,9 +40,6 @@
if (result.error) {
console.error('Sign out error:', result.error);
}
// Resetar tema para padrão ao fazer logout
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
aplicarTemaPadrao();
goto(resolve('/home'));
}
</script>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import ShineEffect from '$lib/components/ShineEffect.svelte';
type Size = 'sm' | 'md' | 'lg' | 'icon';
type Variant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'link';
type Classes = Partial<{
button: string;
content: string;
spinner: string;
}>;
interface Props {
type?: HTMLButtonAttributes['type'];
disabled?: boolean;
loading?: boolean;
loadingText?: string;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
shine?: boolean;
left?: Snippet;
right?: Snippet;
children?: Snippet;
classes?: Classes;
class?: string;
}
let {
type = 'button',
disabled = false,
loading = false,
loadingText,
size = 'md',
variant = 'primary',
fullWidth = false,
shine = false,
left,
right,
children,
classes,
class: className = ''
}: Props = $props();
const isDisabled = $derived(disabled || loading);
const sizeClass = $derived(
size === 'sm'
? 'px-3 py-2 text-sm'
: size === 'lg'
? 'px-5 py-4 text-base'
: size === 'icon'
? 'p-2'
: 'px-4 py-3.5 text-sm'
);
const base =
'relative inline-flex items-center justify-center gap-2 rounded-xl font-bold transition-all duration-300 overflow-hidden';
const variantClass = $derived(
variant === 'primary'
? 'bg-primary hover:bg-primary-focus hover:shadow-primary/25 text-primary-content shadow-lg'
: variant === 'secondary'
? 'bg-base-200 hover:bg-base-300 text-base-content'
: variant === 'ghost'
? 'bg-transparent hover:bg-base-200/60 text-base-content'
: variant === 'outline'
? 'border-base-content/20 hover:bg-base-200/40 text-base-content border'
: variant === 'danger'
? 'bg-error hover:bg-error/90 text-error-content shadow-lg'
: 'bg-transparent hover:underline text-primary px-0 py-0 rounded-none'
);
const widthClass = $derived(fullWidth ? 'w-full' : '');
const disabledClass = 'disabled:cursor-not-allowed disabled:opacity-50';
const buttonClass = $derived(
[base, sizeClass, variantClass, widthClass, disabledClass, classes?.button, className]
.filter(Boolean)
.join(' ')
);
</script>
<button {type} class={buttonClass} disabled={isDisabled}>
<div
class={['relative z-10 flex items-center justify-center gap-2', classes?.content].filter(
Boolean
)}
>
{#if loading}
<span
class={[
'border-primary-content/30 border-t-primary-content h-5 w-5 animate-spin rounded-full border-2',
classes?.spinner
].filter(Boolean)}
></span>
{#if loadingText}
<span>{loadingText}</span>
{/if}
{:else}
{#if left}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</span>
{/if}
{@render children?.()}
{#if right}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</span>
{/if}
{/if}
</div>
{#if shine}
<ShineEffect />
{/if}
</button>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import type { Snippet } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
input: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
value?: string;
type?: HTMLInputAttributes['type'];
name?: string;
placeholder?: string;
autocomplete?: HTMLInputAttributes['autocomplete'];
disabled?: boolean;
readonly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
left?: Snippet;
right?: Snippet;
inputProps?: Omit<
HTMLInputAttributes,
| 'id'
| 'type'
| 'name'
| 'placeholder'
| 'autocomplete'
| 'disabled'
| 'required'
| 'readOnly'
| 'value'
>;
classes?: Classes;
class?: string;
}
let {
id,
label,
value = $bindable(''),
type = 'text',
name,
placeholder = '',
autocomplete,
disabled = false,
readonly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
left,
right,
inputProps,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasLeft = $derived(!!left);
const hasRight = $derived(!!right);
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const paddingLeft = $derived(hasLeft ? 'pl-11' : '');
const paddingRight = $derived(hasRight ? 'pr-11' : '');
const baseInput =
'border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const inputClass = $derived(
[
baseInput,
variantClass,
fullWidth ? 'w-full' : '',
paddingX,
paddingY,
paddingLeft,
paddingRight,
classes?.input
]
.filter(Boolean)
.join(' ')
);
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render right?.()}
</div>
<div class={['group relative', classes?.control].filter(Boolean)}>
{#if left}
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<div class="text-base-content/50 [&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</div>
</div>
{/if}
<Field.Input
{id}
{type}
{name}
{placeholder}
{disabled}
{readonly}
{autocomplete}
{required}
bind:value
{...inputProps}
class={inputClass}
/>
{#if right}
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
<div class="text-base-content/70 [&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</div>
</div>
{/if}
</div>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View File

@@ -0,0 +1,287 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import { Portal } from '@ark-ui/svelte/portal';
import { Select, createListCollection } from '@ark-ui/svelte/select';
import type { Snippet } from 'svelte';
import { Check, ChevronDown, X } from 'lucide-svelte';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
export interface SelectItem {
label: string;
value: string;
disabled?: boolean;
group?: string;
}
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
trigger: string;
content: string;
item: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
items: SelectItem[];
value?: string[];
name?: string;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
multiple?: boolean;
clearable?: boolean;
maxSelected?: number;
positioning?: Select.RootProps<SelectItem>['positioning'];
/** Slot ao lado do label (ex.: link, ação, etc.) */
labelRight?: Snippet;
/** Slot antes do texto no trigger (ex.: ícone) */
triggerLeft?: Snippet;
classes?: Classes;
class?: string;
}
let {
id,
label,
items,
value = $bindable<string[]>([]),
name,
placeholder = 'Selecione...',
disabled = false,
readOnly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
multiple = false,
clearable = true,
maxSelected,
positioning,
labelRight,
triggerLeft,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasGroups = $derived(items.some((i) => i.group));
const canClear = $derived(clearable && value.length > 0 && !disabled && !readOnly);
const collection = $derived(
createListCollection<SelectItem>({
items
})
);
const groups = $derived.by(() => {
if (!hasGroups) return [];
const record: Record<string, SelectItem[]> = {};
for (const item of items) {
const key = item.group ?? 'Opções';
const arr = record[key] ?? [];
arr.push(item);
record[key] = arr;
}
return Object.entries(record);
});
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const baseTrigger =
'border-base-content/10 bg-base-200/25 text-base-content focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const triggerClass = $derived(
[
baseTrigger,
variantClass,
fullWidth ? 'w-full' : '',
'flex items-center justify-between gap-3 text-left',
paddingX,
paddingY,
classes?.trigger
]
.filter(Boolean)
.join(' ')
);
const contentClass = $derived(
[
'bg-base-100 border-base-200 w-[var(--reference-width)] overflow-hidden rounded-xl border shadow-lg',
'max-h-72',
'z-50',
classes?.content
]
.filter(Boolean)
.join(' ')
);
function handleValueChange(details: { value: string[] }) {
let next = details.value;
if (
typeof maxSelected === 'number' &&
maxSelected >= 0 &&
multiple &&
next.length > maxSelected
) {
next = next.slice(0, maxSelected);
}
value = next;
}
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render labelRight?.()}
</div>
<Select.Root
{collection}
{disabled}
{required}
{readOnly}
{invalid}
{multiple}
{positioning}
{value}
onValueChange={handleValueChange}
>
<Select.Control class={classes?.control}>
<Select.Trigger {id} class={triggerClass}>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if triggerLeft}
<span class="text-base-content/60 shrink-0 [&_svg]:h-4 [&_svg]:w-4">
{@render triggerLeft()}
</span>
{/if}
<Select.ValueText
{placeholder}
class="text-base-content/90 data-placeholder-shown:text-base-content/40 truncate"
/>
</div>
<div class="flex shrink-0 items-center gap-2">
{#if canClear}
<Select.ClearTrigger
aria-label="Limpar seleção"
class="text-base-content/50 hover:text-base-content/80 inline-flex items-center justify-center rounded-md p-1 transition-colors"
>
<X class="h-4 w-4" />
</Select.ClearTrigger>
{/if}
<Select.Indicator class="text-base-content/60 [&_svg]:h-4 [&_svg]:w-4">
<ChevronDown />
</Select.Indicator>
</div>
</Select.Trigger>
</Select.Control>
<Portal>
<Select.Positioner>
<Select.Content class={contentClass}>
<div class="p-1">
{#if hasGroups}
{#each groups as [groupLabel, groupItems] (groupLabel)}
<Select.ItemGroup class="mb-1 last:mb-0">
<Select.ItemGroupLabel
class="text-base-content/50 px-3 py-2 text-xs font-semibold tracking-wider uppercase"
>
{groupLabel}
</Select.ItemGroupLabel>
{#each groupItems as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText
>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
</Select.ItemGroup>
{/each}
{:else}
{#each items as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
{/if}
</div>
</Select.Content>
</Select.Positioner>
</Portal>
<Select.HiddenSelect {name} />
</Select.Root>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View File

@@ -144,6 +144,52 @@ export function obterNomeDaisyUI(id: TemaId | string | null | undefined): string
return temaParaDaisyUI[tema.id] || 'aqua';
}
/**
* Lê o tema persistido pelo `theme-change` (chave "theme") no localStorage.
* Retorna o nome do tema do DaisyUI (ex.: "dark", "light", "aqua", "sgse-blue").
*/
export function obterTemaPersistidoNoLocalStorage(chave: string = 'theme'): string | null {
if (typeof window === 'undefined') return null;
try {
const tema = window.localStorage.getItem(chave);
return tema && tema.trim() ? tema : null;
} catch {
return null;
}
}
/**
* Aplica diretamente um tema do DaisyUI no `<html data-theme="...">`.
* (Não altera o localStorage)
*/
export function aplicarTemaDaisyUI(tema: string): void {
if (typeof document === 'undefined') return;
const htmlElement = document.documentElement;
if (!htmlElement) return;
// Normaliza qualquer estado anterior
htmlElement.removeAttribute('data-theme');
// Evita que `body[data-theme]` sobrescreva o tema do `<html>`
if (document.body) document.body.removeAttribute('data-theme');
htmlElement.setAttribute('data-theme', tema);
// Forçar reflow para garantir que o CSS seja aplicado
void htmlElement.offsetHeight;
}
/**
* Garante que o tema do `<html>` reflita SEMPRE o valor persistido no localStorage.
* Se não houver tema persistido, aplica o tema padrão.
*/
export function aplicarTemaDoLocalStorage(): void {
const temaPersistido = obterTemaPersistidoNoLocalStorage('theme');
if (temaPersistido) {
aplicarTemaDaisyUI(temaPersistido);
return;
}
aplicarTemaPadrao();
}
/**
* Aplicar tema ao documento HTML
* NÃO salva no localStorage - apenas no banco de dados do usuário