@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
128
apps/web/src/lib/components/ui/Button.svelte
Normal file
128
apps/web/src/lib/components/ui/Button.svelte
Normal 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>
|
||||
174
apps/web/src/lib/components/ui/Input.svelte
Normal file
174
apps/web/src/lib/components/ui/Input.svelte
Normal 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>
|
||||
287
apps/web/src/lib/components/ui/Select.svelte
Normal file
287
apps/web/src/lib/components/ui/Select.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user