@@ -2,6 +2,8 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import logo from '$lib/assets/logo_governo_PE.png';
|
import logo from '$lib/assets/logo_governo_PE.png';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
left?: Snippet;
|
left?: Snippet;
|
||||||
@@ -9,6 +11,43 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { left, right }: HeaderProps = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -36,9 +75,11 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
|
bind:this={themeSelectEl}
|
||||||
class="select select-sm bg-base-100 border-base-300 w-40"
|
class="select select-sm bg-base-100 border-base-300 w-40"
|
||||||
aria-label="Selecionar tema"
|
aria-label="Selecionar tema"
|
||||||
data-choose-theme
|
data-choose-theme
|
||||||
|
onchange={onThemeChange}
|
||||||
>
|
>
|
||||||
<option value="aqua">Aqua</option>
|
<option value="aqua">Aqua</option>
|
||||||
<option value="sgse-blue">Azul</option>
|
<option value="sgse-blue">Azul</option>
|
||||||
|
|||||||
@@ -40,9 +40,6 @@
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error('Sign out error:', 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'));
|
goto(resolve('/home'));
|
||||||
}
|
}
|
||||||
</script>
|
</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';
|
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
|
* Aplicar tema ao documento HTML
|
||||||
* NÃO salva no localStorage - apenas no banco de dados do usuário
|
* NÃO salva no localStorage - apenas no banco de dados do usuário
|
||||||
|
|||||||
Reference in New Issue
Block a user