@h-ai/ui 0.1.0-alpha.25 → 0.1.0-alpha.27

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.
Files changed (37) hide show
  1. package/README.md +28 -8
  2. package/auto-import.d.ts +5 -0
  3. package/auto-import.js +4 -0
  4. package/dist/components/compounds/Calendar.svelte +1 -1
  5. package/dist/components/compounds/DataTable.svelte +81 -4
  6. package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
  7. package/dist/components/compounds/DatePicker.svelte +1 -1
  8. package/dist/components/compounds/Modal.svelte +44 -6
  9. package/dist/components/compounds/Pagination.svelte +72 -119
  10. package/dist/components/compounds/ToastContainer.svelte +159 -20
  11. package/dist/components/scenes/app/SettingsLayout.svelte +111 -0
  12. package/dist/components/scenes/app/SettingsLayout.svelte.d.ts +41 -0
  13. package/dist/components/scenes/app/ThemeColorPicker.svelte +41 -0
  14. package/dist/components/scenes/app/index.d.ts +1 -0
  15. package/dist/components/scenes/app/index.js +1 -0
  16. package/dist/components/scenes/crud/CrudFilterBar.svelte +173 -35
  17. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +7 -1
  18. package/dist/components/scenes/crud/CrudPage.svelte +19 -15
  19. package/dist/components/scenes/error/ErrorPage.svelte +125 -0
  20. package/dist/components/scenes/error/ErrorPage.svelte.d.ts +42 -0
  21. package/dist/components/scenes/error/error-presets.d.ts +24 -0
  22. package/dist/components/scenes/error/error-presets.js +31 -0
  23. package/dist/components/scenes/error/index.d.ts +8 -0
  24. package/dist/components/scenes/error/index.js +8 -0
  25. package/dist/components/scenes/iam/AuthShell.svelte +138 -0
  26. package/dist/components/scenes/iam/AuthShell.svelte.d.ts +45 -0
  27. package/dist/components/scenes/iam/index.d.ts +1 -0
  28. package/dist/components/scenes/iam/index.js +2 -1
  29. package/dist/components/scenes/index.d.ts +1 -0
  30. package/dist/components/scenes/index.js +2 -0
  31. package/dist/messages/en-US.json +20 -1
  32. package/dist/messages/zh-CN.json +20 -1
  33. package/dist/styles/global.css +4 -1
  34. package/dist/types.d.ts +6 -0
  35. package/package.json +9 -3
  36. package/vite.d.ts +13 -0
  37. package/vite.js +95 -0
@@ -4,10 +4,17 @@
4
4
  =============================================================================
5
5
  Toast 容器组件,显示所有 Toast 通知
6
6
 
7
- 使用 Svelte 5 Runes ($derived)
7
+ 使用 Svelte 5 Runes ($derived / $effect)
8
+
9
+ 叠加策略(关键):原生 <dialog>.showModal() 处于浏览器 top layer,且在 Chromium 中
10
+ 始终渲染在 Popover 之上。为保证 Toast 永远可见、不被弹窗遮挡:
11
+ - 当存在已打开的 modal dialog 时,将 Toast 层寄宿到该 dialog 内部(同处 top layer,
12
+ 作为最后子节点渲染在其内容之上);
13
+ - 否则寄宿到 <body> 并以 Popover 进入 top layer,叠加在普通内容之上。
8
14
  =============================================================================
9
15
  -->
10
16
  <script lang='ts'>
17
+ import { onMount } from 'svelte'
11
18
  import { uiM } from '../../messages.js'
12
19
  import { toast } from '../../toast.svelte.js'
13
20
  import { cn, getAlertVariantClass } from '../../utils.js'
@@ -22,6 +29,11 @@
22
29
  'bottom-center': 'toast-bottom toast-center',
23
30
  }
24
31
 
32
+ // 容器根元素引用。
33
+ let layerElement: HTMLDivElement | undefined = $state()
34
+
35
+ const hasItems = $derived(toast.items.length > 0)
36
+
25
37
  // 按位置分组
26
38
  const groupedItems = $derived(() => {
27
39
  const groups: Record<string, typeof toast.items> = {}
@@ -36,24 +48,151 @@
36
48
 
37
49
  return groups
38
50
  })
51
+
52
+ /** 找到最顶层处于打开状态的原生 modal dialog(位于浏览器 top layer)。 */
53
+ function findTopModalDialog(): HTMLDialogElement | null {
54
+ if (typeof document === 'undefined') {
55
+ return null
56
+ }
57
+
58
+ const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog')
59
+ let top: HTMLDialogElement | null = null
60
+ for (const dialog of dialogs) {
61
+ if (dialog.open && typeof dialog.matches === 'function' && dialog.matches(':modal')) {
62
+ top = dialog
63
+ }
64
+ }
65
+ return top
66
+ }
67
+
68
+ function safeShowPopover(el: HTMLElement) {
69
+ try {
70
+ if (typeof el.showPopover === 'function' && el.isConnected && !el.matches(':popover-open')) {
71
+ el.showPopover()
72
+ }
73
+ }
74
+ catch {
75
+ // 元素未连接或重复调用时忽略。
76
+ }
77
+ }
78
+
79
+ function safeHidePopover(el: HTMLElement) {
80
+ try {
81
+ if (typeof el.hidePopover === 'function' && el.matches(':popover-open')) {
82
+ el.hidePopover()
83
+ }
84
+ }
85
+ catch {
86
+ // 同上,忽略异常。
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 将 Toast 层寄宿到正确的容器:
92
+ * - 有打开的 modal dialog 时,去掉 popover 属性并寄宿到该 dialog 内部,渲染在其内容之上;
93
+ * - 否则寄宿到 <body>,恢复 popover 属性并按需进入 / 退出 top layer。
94
+ */
95
+ function relocate() {
96
+ const el = layerElement
97
+ if (!el) {
98
+ return
99
+ }
100
+
101
+ const modal = hasItems ? findTopModalDialog() : null
102
+
103
+ if (modal) {
104
+ // 寄宿到 dialog:popover 元素未显示时会 display:none,故先移除 popover 属性。
105
+ safeHidePopover(el)
106
+ if (el.hasAttribute('popover')) {
107
+ el.removeAttribute('popover')
108
+ }
109
+ if (el.parentElement !== modal) {
110
+ modal.appendChild(el)
111
+ }
112
+ return
113
+ }
114
+
115
+ if (el.parentElement !== document.body) {
116
+ document.body.appendChild(el)
117
+ }
118
+ if (!el.hasAttribute('popover')) {
119
+ el.setAttribute('popover', 'manual')
120
+ }
121
+ if (hasItems) {
122
+ safeShowPopover(el)
123
+ }
124
+ else {
125
+ safeHidePopover(el)
126
+ }
127
+ }
128
+
129
+ onMount(() => {
130
+ relocate()
131
+
132
+ // dialog 通过 showModal()/close() 切换 `open` 属性;据此重新寄宿 Toast 层。
133
+ const observer = new MutationObserver(() => relocate())
134
+ observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['open'] })
135
+
136
+ return () => {
137
+ observer.disconnect()
138
+ const el = layerElement
139
+ if (el) {
140
+ safeHidePopover(el)
141
+ el.remove()
142
+ }
143
+ }
144
+ })
145
+
146
+ // Toast 数量变化时重新寄宿(出现 / 消失、以及在 dialog 与 body 之间切换)。
147
+ $effect(() => {
148
+ void hasItems
149
+ relocate()
150
+ })
39
151
  </script>
40
152
 
41
- {#each Object.entries(groupedItems()) as [position, items] (position)}
42
- <div class={cn('toast z-50', positionMap[position as keyof typeof positionMap])}>
43
- {#each items as item (item.id)}
44
- <div class={cn('alert', getAlertVariantClass(item.variant ?? 'info'))}>
45
- <span>{item.message}</span>
46
- {#if item.dismissible}
47
- <IconButton
48
- variant='ghost'
49
- size='sm'
50
- onclick={() => toast.remove(item.id)}
51
- ariaLabel={uiM('toast_dismiss')}
52
- >
53
- ✕
54
- </IconButton>
55
- {/if}
56
- </div>
57
- {/each}
58
- </div>
59
- {/each}
153
+ <div bind:this={layerElement} popover='manual' class='hai-toast-layer'>
154
+ {#each Object.entries(groupedItems()) as [position, items] (position)}
155
+ <div class={cn('toast', positionMap[position as keyof typeof positionMap])}>
156
+ {#each items as item (item.id)}
157
+ <div class={cn('alert', getAlertVariantClass(item.variant ?? 'info'))}>
158
+ <span>{item.message}</span>
159
+ {#if item.dismissible}
160
+ <IconButton
161
+ variant='ghost'
162
+ size='sm'
163
+ onclick={() => toast.remove(item.id)}
164
+ ariaLabel={uiM('toast_dismiss')}
165
+ >
166
+ ✕
167
+ </IconButton>
168
+ {/if}
169
+ </div>
170
+ {/each}
171
+ </div>
172
+ {/each}
173
+ </div>
174
+
175
+ <style>
176
+ /* 容器铺满视口但不拦截交互;仅 toast 本体接收指针事件。 */
177
+ .hai-toast-layer {
178
+ position: fixed;
179
+ inset: 0;
180
+ width: 100%;
181
+ height: 100%;
182
+ max-width: none;
183
+ max-height: none;
184
+ margin: 0;
185
+ padding: 0;
186
+ border: 0;
187
+ background: transparent;
188
+ overflow: visible;
189
+ pointer-events: none;
190
+ }
191
+
192
+ /* 未支持 Popover API 的浏览器忽略 popover 属性,容器常驻文档流;
193
+ 此时无 toast 项则内部不渲染任何内容,容器透明且不拦截交互。 */
194
+
195
+ .hai-toast-layer :global(.toast) {
196
+ pointer-events: auto;
197
+ }
198
+ </style>
@@ -0,0 +1,111 @@
1
+ <!--
2
+ @component SettingsLayout
3
+ 设置页布局场景组件
4
+
5
+ 借鉴 shadcn-admin 设置页:顶部标题/描述 + 左侧分区导航 + 右侧内容区。
6
+ 分区内容由使用方通过 children 提供,导航高亮由 active 控制。
7
+
8
+ 使用 Svelte 5 Runes ($props, $derived)
9
+
10
+ @example
11
+ <SettingsLayout
12
+ title="设置"
13
+ description="管理你的账户与偏好"
14
+ sections={[{ id: 'appearance', label: '外观', icon: 'icon-[tabler--palette]' }]}
15
+ active={active}
16
+ onselect={(id) => active = id}
17
+ >
18
+ {#if active === 'appearance'}<AppearanceSection />{/if}
19
+ </SettingsLayout>
20
+ -->
21
+ <script lang='ts'>
22
+ import type { Snippet } from 'svelte'
23
+ import { cn } from '../../../utils.js'
24
+ import BareButton from '../../primitives/BareButton.svelte'
25
+
26
+ type SettingsSection = {
27
+ id: string
28
+ label: string
29
+ icon?: string
30
+ /** 提供时渲染为链接(SSR 友好),否则触发 onselect */
31
+ href?: string
32
+ }
33
+
34
+ const {
35
+ title = '',
36
+ description = '',
37
+ sections = [],
38
+ active = '',
39
+ onselect,
40
+ children,
41
+ headerActions,
42
+ class: className = '',
43
+ }: {
44
+ title?: string
45
+ description?: string
46
+ sections?: SettingsSection[]
47
+ active?: string
48
+ onselect?: (id: string) => void
49
+ children?: Snippet
50
+ headerActions?: Snippet
51
+ class?: string
52
+ } = $props()
53
+
54
+ function navItemClass(id: string): string {
55
+ return cn(
56
+ 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
57
+ id === active
58
+ ? 'bg-base-content/8 text-base-content'
59
+ : 'text-base-content/60 hover:bg-base-content/5 hover:text-base-content',
60
+ )
61
+ }
62
+ </script>
63
+
64
+ <div class={cn('space-y-5', className)}>
65
+ <!-- 标题区 -->
66
+ {#if title || description || headerActions}
67
+ <div class='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
68
+ <div>
69
+ {#if title}
70
+ <h1 class='text-xl font-semibold tracking-tight'>{title}</h1>
71
+ {/if}
72
+ {#if description}
73
+ <p class='mt-0.5 text-sm text-base-content/45'>{description}</p>
74
+ {/if}
75
+ </div>
76
+ {#if headerActions}
77
+ <div class='flex items-center gap-2'>{@render headerActions()}</div>
78
+ {/if}
79
+ </div>
80
+ {/if}
81
+
82
+ <div class='grid gap-6 lg:grid-cols-[13rem_minmax(0,1fr)]'>
83
+ <!-- 左侧分区导航 -->
84
+ {#if sections.length > 0}
85
+ <aside class='lg:sticky lg:top-4 lg:self-start'>
86
+ <nav class='flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible'>
87
+ {#each sections as section (section.id)}
88
+ {#if section.href}
89
+ <a href={section.href} class={navItemClass(section.id)} aria-current={section.id === active ? 'page' : undefined}>
90
+ {#if section.icon}<span class='{section.icon} size-4 shrink-0'></span>{/if}
91
+ <span class='whitespace-nowrap'>{section.label}</span>
92
+ </a>
93
+ {:else}
94
+ <BareButton type='button' class={navItemClass(section.id)} onclick={() => onselect?.(section.id)}>
95
+ {#if section.icon}<span class='{section.icon} size-4 shrink-0'></span>{/if}
96
+ <span class='whitespace-nowrap'>{section.label}</span>
97
+ </BareButton>
98
+ {/if}
99
+ {/each}
100
+ </nav>
101
+ </aside>
102
+ {/if}
103
+
104
+ <!-- 内容区 -->
105
+ <div class='min-w-0 space-y-5'>
106
+ {#if children}
107
+ {@render children()}
108
+ {/if}
109
+ </div>
110
+ </div>
111
+ </div>
@@ -0,0 +1,41 @@
1
+ import type { Snippet } from 'svelte';
2
+ type SettingsSection = {
3
+ id: string;
4
+ label: string;
5
+ icon?: string;
6
+ /** 提供时渲染为链接(SSR 友好),否则触发 onselect */
7
+ href?: string;
8
+ };
9
+ type $$ComponentProps = {
10
+ title?: string;
11
+ description?: string;
12
+ sections?: SettingsSection[];
13
+ active?: string;
14
+ onselect?: (id: string) => void;
15
+ children?: Snippet;
16
+ headerActions?: Snippet;
17
+ class?: string;
18
+ };
19
+ /**
20
+ * SettingsLayout
21
+ * 设置页布局场景组件
22
+ *
23
+ * 借鉴 shadcn-admin 设置页:顶部标题/描述 + 左侧分区导航 + 右侧内容区。
24
+ * 分区内容由使用方通过 children 提供,导航高亮由 active 控制。
25
+ *
26
+ * 使用 Svelte 5 Runes ($props, $derived)
27
+ *
28
+ * @example
29
+ * <SettingsLayout
30
+ * title="设置"
31
+ * description="管理你的账户与偏好"
32
+ * sections={[{ id: 'appearance', label: '外观', icon: 'icon-[tabler--palette]' }]}
33
+ * active={active}
34
+ * onselect={(id) => active = id}
35
+ * >
36
+ * {#if active === 'appearance'}<AppearanceSection />{/if}
37
+ * </SettingsLayout>
38
+ */
39
+ declare const SettingsLayout: import("svelte").Component<$$ComponentProps, {}, "">;
40
+ type SettingsLayout = ReturnType<typeof SettingsLayout>;
41
+ export default SettingsLayout;
@@ -163,3 +163,44 @@
163
163
  />
164
164
  </div>
165
165
  </div>
166
+
167
+ <style>
168
+ button {
169
+ border-color: var(--soft-border, color-mix(in oklab, var(--color-base-content) 12%, transparent));
170
+ background: var(--soft-surface, var(--color-base-100));
171
+ color: var(--soft-text-body, var(--color-base-content));
172
+ box-shadow: none;
173
+ font-weight: 500;
174
+ }
175
+
176
+ button:hover:not(:disabled) {
177
+ border-color: color-mix(in oklab, var(--color-primary) 28%, var(--soft-border, transparent) 72%);
178
+ background: color-mix(
179
+ in oklab,
180
+ var(--soft-surface, var(--color-base-100)) 88%,
181
+ var(--color-base-200) 12%
182
+ );
183
+ }
184
+
185
+ button[aria-pressed='true'] {
186
+ border-color: color-mix(in oklab, var(--color-primary) 42%, var(--soft-border, transparent) 58%);
187
+ background: color-mix(
188
+ in oklab,
189
+ var(--color-primary) 10%,
190
+ var(--soft-surface, var(--color-base-100)) 90%
191
+ );
192
+ color: var(--color-primary);
193
+ }
194
+
195
+ button:focus,
196
+ button:focus-visible {
197
+ outline: none;
198
+ border-color: color-mix(in oklab, var(--color-primary) 48%, var(--soft-border, transparent) 52%);
199
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-primary) 12%, transparent 88%);
200
+ }
201
+
202
+ button > span:first-child {
203
+ border-color: color-mix(in oklab, var(--color-base-content) 10%, transparent 90%);
204
+ box-shadow: none;
205
+ }
206
+ </style>
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export { default as FeedbackModal } from './FeedbackModal.svelte';
8
8
  export { default as LanguageSwitch } from './LanguageSwitch.svelte';
9
+ export { default as SettingsLayout } from './SettingsLayout.svelte';
9
10
  export { default as SettingsModal } from './SettingsModal.svelte';
10
11
  export { default as ThemeColorPicker } from './ThemeColorPicker.svelte';
11
12
  export { default as ThemeSelector } from './ThemeSelector.svelte';
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export { default as FeedbackModal } from './FeedbackModal.svelte';
8
8
  export { default as LanguageSwitch } from './LanguageSwitch.svelte';
9
+ export { default as SettingsLayout } from './SettingsLayout.svelte';
9
10
  export { default as SettingsModal } from './SettingsModal.svelte';
10
11
  export { default as ThemeColorPicker } from './ThemeColorPicker.svelte';
11
12
  export { default as ThemeSelector } from './ThemeSelector.svelte';
@@ -6,6 +6,7 @@
6
6
  -->
7
7
  <script lang='ts'>
8
8
  import { uiM } from '../../../messages.js'
9
+ import BareButton from '../../primitives/BareButton.svelte'
9
10
  import Input from '../../primitives/Input.svelte'
10
11
  import Select from '../../primitives/Select.svelte'
11
12
 
@@ -13,6 +14,12 @@
13
14
  id: string
14
15
  label: string | (() => string)
15
16
  type: string
17
+ placeholder?: string | (() => string)
18
+ startKey?: string
19
+ endKey?: string
20
+ min?: number
21
+ max?: number
22
+ step?: number
16
23
  options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
17
24
  }
18
25
 
@@ -22,22 +29,62 @@
22
29
  searchValue = $bindable(''),
23
30
  filterFields = [],
24
31
  filterValues = $bindable<Record<string, unknown>>({}),
25
- total = 0,
26
32
  onsearch,
27
33
  onfilterchange,
34
+ onreset,
28
35
  }: {
29
36
  searchable?: boolean
30
37
  searchPlaceholder?: string
31
38
  searchValue?: string
32
39
  filterFields?: FilterFieldDef[]
33
40
  filterValues?: Record<string, unknown>
34
- total?: number
35
41
  onsearch?: (search: string) => void
36
42
  onfilterchange?: (filters: Record<string, unknown>) => void
43
+ onreset?: () => void
37
44
  } = $props()
38
45
 
39
46
  let searchTimer: ReturnType<typeof setTimeout> | undefined
40
47
 
48
+ // 是否存在已激活的搜索/过滤条件(用于显示重置按钮)
49
+ const hasFilterValue = $derived(Object.values(filterValues).some(v => v !== undefined && v !== '' && v !== null))
50
+ const hasActiveFilters = $derived(searchValue.trim() !== '' || hasFilterValue)
51
+
52
+ const dateLikeTypes = new Set(['date', 'datetime-local', 'datetime', 'month', 'week'])
53
+ const numberLikeTypes = new Set(['number', 'integer'])
54
+
55
+ function resolveText(text: string | (() => string) | undefined): string {
56
+ if (!text)
57
+ return ''
58
+ return typeof text === 'function' ? text() : text
59
+ }
60
+
61
+ function normalizeType(type: string): string {
62
+ return type.trim().toLowerCase()
63
+ }
64
+
65
+ function isBooleanField(field: FilterFieldDef): boolean {
66
+ const t = normalizeType(field.type)
67
+ return t === 'boolean' || t === 'switch' || t === 'toggle'
68
+ }
69
+
70
+ function isDateRangeField(field: FilterFieldDef): boolean {
71
+ const t = normalizeType(field.type)
72
+ return t === 'date-range' || t === 'daterange'
73
+ }
74
+
75
+ function isDateField(field: FilterFieldDef): boolean {
76
+ return dateLikeTypes.has(normalizeType(field.type))
77
+ }
78
+
79
+ function isNumberField(field: FilterFieldDef): boolean {
80
+ return numberLikeTypes.has(normalizeType(field.type))
81
+ }
82
+
83
+ function isTextField(field: FilterFieldDef): boolean {
84
+ const t = normalizeType(field.type)
85
+ return t === 'text' || t === 'search' || t === 'keyword'
86
+ }
87
+
41
88
  function resolveOptions(options?: FilterFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
42
89
  if (!options)
43
90
  return []
@@ -55,46 +102,137 @@
55
102
  filterValues = { ...filterValues, [fieldId]: value }
56
103
  onfilterchange?.(filterValues)
57
104
  }
105
+
106
+ function handleFilterPatch(partial: Record<string, unknown>) {
107
+ filterValues = { ...filterValues, ...partial }
108
+ onfilterchange?.(filterValues)
109
+ }
110
+
111
+ function handleReset() {
112
+ clearTimeout(searchTimer)
113
+ searchValue = ''
114
+ filterValues = {}
115
+ onreset?.()
116
+ }
58
117
  </script>
59
118
 
60
- <div class='grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center'>
61
- <div class='flex min-w-0 flex-wrap items-center gap-3 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
62
- {#if searchable}
63
- <div class='relative min-w-[15rem] flex-[1_1_20rem]'>
64
- <span class='icon-[tabler--search] pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
119
+ <div class='flex flex-wrap items-center gap-2 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
120
+ {#if searchable}
121
+ <div class='relative w-full sm:w-64 md:w-72'>
122
+ <span class='icon-[tabler--search] pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
123
+ <Input
124
+ type='text'
125
+ size='sm'
126
+ placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
127
+ class='h-8 pl-9 shadow-none'
128
+ bind:value={searchValue}
129
+ oninput={handleSearchInput}
130
+ autocomplete='off'
131
+ />
132
+ </div>
133
+ {/if}
134
+
135
+ {#each filterFields as field (field.id)}
136
+ {@const fieldPlaceholder = resolveText(field.placeholder)}
137
+ {@const opts = resolveOptions(field.options)}
138
+
139
+ {#if isDateRangeField(field)}
140
+ {@const startKey = field.startKey ?? `${field.id}Start`}
141
+ {@const endKey = field.endKey ?? `${field.id}End`}
142
+ <div class='flex items-center gap-1.5'>
143
+ <div class='w-32 shrink-0'>
144
+ <input
145
+ type='date'
146
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
147
+ aria-label={fieldPlaceholder || uiM('crud_filter_from')}
148
+ value={String(filterValues[startKey] ?? '')}
149
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterPatch({ [startKey]: e.currentTarget.value })}
150
+ />
151
+ </div>
152
+ <span class='text-sm text-base-content/40'>~</span>
153
+ <div class='w-32 shrink-0'>
154
+ <input
155
+ type='date'
156
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
157
+ aria-label={uiM('crud_filter_to')}
158
+ value={String(filterValues[endKey] ?? '')}
159
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterPatch({ [endKey]: e.currentTarget.value })}
160
+ />
161
+ </div>
162
+ </div>
163
+ {:else if isBooleanField(field)}
164
+ <div class='w-30 shrink-0'>
165
+ <Select
166
+ size='sm'
167
+ class='h-8 border-base-content/20 bg-base-100 shadow-none'
168
+ value={String(filterValues[field.id] ?? '')}
169
+ onchange={(value: string) => handleFilterChange(field.id, value)}
170
+ >
171
+ <option value="">{uiM('crud_filter_all')}</option>
172
+ <option value='true'>{uiM('crud_filter_yes')}</option>
173
+ <option value='false'>{uiM('crud_filter_no')}</option>
174
+ </Select>
175
+ </div>
176
+ {:else if isDateField(field)}
177
+ <div class='w-36 shrink-0'>
178
+ <input
179
+ type='date'
180
+ class='h-8 w-full rounded-lg border border-base-content/15 bg-base-100 px-2.5 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
181
+ aria-label={fieldPlaceholder || resolveText(field.label)}
182
+ value={String(filterValues[field.id] ?? '')}
183
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
184
+ />
185
+ </div>
186
+ {:else if isNumberField(field)}
187
+ <div class='w-28 shrink-0'>
188
+ <Input
189
+ type='number'
190
+ size='sm'
191
+ min={field.min}
192
+ max={field.max}
193
+ step={field.step}
194
+ class='h-8 shadow-none'
195
+ placeholder={fieldPlaceholder || resolveText(field.label)}
196
+ value={String(filterValues[field.id] ?? '')}
197
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
198
+ />
199
+ </div>
200
+ {:else if opts.length > 0}
201
+ <div class='w-34 shrink-0'>
202
+ <Select
203
+ size='sm'
204
+ class='h-8 border-base-content/20 bg-base-100 shadow-none'
205
+ value={String(filterValues[field.id] ?? '')}
206
+ onchange={(value: string) => handleFilterChange(field.id, value)}
207
+ >
208
+ <option value="">{uiM('crud_filter_all')}</option>
209
+ {#each opts as opt (String(opt.value))}
210
+ <option value={String(opt.value)}>{opt.label}</option>
211
+ {/each}
212
+ </Select>
213
+ </div>
214
+ {:else if isTextField(field)}
215
+ <div class='w-44 shrink-0'>
65
216
  <Input
66
217
  type='text'
67
218
  size='sm'
68
- placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
69
- class='pl-10 shadow-none'
70
- bind:value={searchValue}
71
- oninput={handleSearchInput}
72
- autocomplete='off'
219
+ class='h-8 shadow-none'
220
+ placeholder={fieldPlaceholder || resolveText(field.label)}
221
+ value={String(filterValues[field.id] ?? '')}
222
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) => handleFilterChange(field.id, e.currentTarget.value)}
73
223
  />
74
224
  </div>
75
225
  {/if}
226
+ {/each}
76
227
 
77
- {#each filterFields as field (field.id)}
78
- {@const opts = resolveOptions(field.options)}
79
- {#if opts.length > 0}
80
- <div class='w-full sm:w-[11rem] sm:flex-none'>
81
- <Select
82
- size='sm'
83
- class='border-base-content/10 bg-base-100 shadow-none'
84
- value={String(filterValues[field.id] ?? '')}
85
- onchange={value => handleFilterChange(field.id, value)}
86
- >
87
- <option value="">{uiM('crud_filter_all')}</option>
88
- {#each opts as opt (String(opt.value))}
89
- <option value={String(opt.value)}>{opt.label}</option>
90
- {/each}
91
- </Select>
92
- </div>
93
- {/if}
94
- {/each}
95
- </div>
96
-
97
- <div class='inline-flex items-center justify-self-start rounded-full border border-base-content/8 bg-base-200/60 px-3 py-1 text-xs font-medium text-base-content/60 md:justify-self-end'>
98
- {uiM('crud_total', { count: total })}
99
- </div>
228
+ {#if hasActiveFilters}
229
+ <BareButton
230
+ type='button'
231
+ class='inline-flex h-8 items-center gap-1 rounded-md px-2.5 text-sm font-medium text-base-content/60 transition-colors hover:bg-base-content/5 hover:text-base-content'
232
+ onclick={handleReset}
233
+ >
234
+ {uiM('crud_reset')}
235
+ <span class='icon-[tabler--x] size-3.5'></span>
236
+ </BareButton>
237
+ {/if}
100
238
  </div>