@joewinke/jatui 0.36.2 → 0.37.0
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.
- package/dist/actions/floating.js +12 -2
- package/dist/components/Button.svelte +8 -0
- package/dist/components/ContextMenu.svelte +44 -3
- package/dist/components/ContextMenu.svelte.d.ts +11 -1
- package/dist/components/TextInput.svelte +39 -6
- package/dist/components/TextInput.svelte.d.ts +15 -7
- package/dist/components/basic/Checkbox.svelte +52 -14
- package/dist/components/basic/Checkbox.svelte.d.ts +11 -1
- package/package.json +1 -1
package/dist/actions/floating.js
CHANGED
|
@@ -132,10 +132,20 @@ export function floating(node, options) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
startAutoUpdate();
|
|
135
|
-
// Animate in after first position has been applied (next microtask)
|
|
135
|
+
// Animate in after first position has been applied (next microtask).
|
|
136
|
+
// The end value is `none`, NOT `translateY(0)`: a non-none inline transform
|
|
137
|
+
// makes this element the CONTAINING BLOCK for any descendant
|
|
138
|
+
// `position: fixed` popover — a nested ContextMenu submenu is rendered
|
|
139
|
+
// inside this panel — silently re-anchoring that popover's viewport
|
|
140
|
+
// coordinates to this element (MOS jatui-fjch2: a submenu rendered on top
|
|
141
|
+
// of its parent menu). `none` still interpolates from translateY(2px)
|
|
142
|
+
// through the identity matrix, so the entrance animation is visually
|
|
143
|
+
// identical, and once it completes the element no longer creates a
|
|
144
|
+
// containing block. (opacity < 1 only creates a stacking context, not a
|
|
145
|
+
// containing block, so it is safe to keep.)
|
|
136
146
|
requestAnimationFrame(() => {
|
|
137
147
|
node.style.opacity = '1';
|
|
138
|
-
node.style.transform = '
|
|
148
|
+
node.style.transform = 'none';
|
|
139
149
|
});
|
|
140
150
|
// Defer outside-pointer listeners to avoid catching the opening event itself
|
|
141
151
|
const listenerTimer = setTimeout(() => {
|
|
@@ -187,6 +187,14 @@
|
|
|
187
187
|
{/if}
|
|
188
188
|
|
|
189
189
|
<style>
|
|
190
|
+
/* ── Flat by default ──────────────────────────────────────────
|
|
191
|
+
DaisyUI v5 gives colored variants (primary, error, …) a drop shadow
|
|
192
|
+
through --btn-shadow. jatui buttons are flat. Only the variable is
|
|
193
|
+
cleared, so a consumer `shadow-*` utility still applies when wanted. */
|
|
194
|
+
.jatui-btn {
|
|
195
|
+
--btn-shadow: 0 0 0 0 transparent;
|
|
196
|
+
}
|
|
197
|
+
|
|
190
198
|
/* ── Press Squish ─────────────────────────────────────────────
|
|
191
199
|
Quick down on :active, spring back on release. Transform-only.
|
|
192
200
|
Local @media guard — motion.css is the backstop but not guaranteed. */
|
|
@@ -68,9 +68,19 @@
|
|
|
68
68
|
* menus; the component sets this to `false` when it recursively renders
|
|
69
69
|
* itself as a nested submenu so that arrow-navigating over a parent item
|
|
70
70
|
* with a submenu doesn't yank keyboard focus out of the parent row.
|
|
71
|
-
*
|
|
71
|
+
* The initial programmatic autofocus does NOT open a submenu that
|
|
72
|
+
* happens to live on the first item — a submenu opens on hover, click,
|
|
73
|
+
* Enter/Space or ArrowRight only. ArrowRight from the parent explicitly
|
|
74
|
+
* focuses into the submenu.
|
|
72
75
|
*/
|
|
73
76
|
autoFocus?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Floating-ui placement for this menu panel. Top-level menus anchor at
|
|
79
|
+
* the cursor and open 'bottom-start'; a nested submenu passes
|
|
80
|
+
* 'left-start' when it must flip to the parent's LEFT edge because
|
|
81
|
+
* there is no room to the right of it.
|
|
82
|
+
*/
|
|
83
|
+
placement?: 'bottom-start' | 'left-start';
|
|
74
84
|
/**
|
|
75
85
|
* Called when an action item (not a submenu owner) is chosen, before
|
|
76
86
|
* this menu closes. A nested submenu uses it to close the WHOLE menu
|
|
@@ -89,6 +99,7 @@
|
|
|
89
99
|
class: className = '',
|
|
90
100
|
onClose,
|
|
91
101
|
autoFocus = true,
|
|
102
|
+
placement: placementProp = 'bottom-start',
|
|
92
103
|
onItemSelected
|
|
93
104
|
}: Props = $props();
|
|
94
105
|
|
|
@@ -97,6 +108,15 @@
|
|
|
97
108
|
let submenuIndex = $state<number | null>(null);
|
|
98
109
|
let submenuX = $state(0);
|
|
99
110
|
let submenuY = $state(0);
|
|
111
|
+
// 'bottom-start' when the submenu opens to the right of the parent item,
|
|
112
|
+
// 'left-start' when it must flip to the parent's LEFT edge (viewport edge).
|
|
113
|
+
let submenuPlacement = $state<'bottom-start' | 'left-start'>('bottom-start');
|
|
114
|
+
|
|
115
|
+
// Suppresses the 'onfocus' open-submenu side effect DURING the menu's
|
|
116
|
+
// own initial programmatic autofocus, so a first item that happens to
|
|
117
|
+
// own a submenu does not instantly open it before any user interaction
|
|
118
|
+
// (the submenu still opens on hover, click, Enter/Space or ArrowRight).
|
|
119
|
+
let suppressFocusOpen = false;
|
|
100
120
|
|
|
101
121
|
/**
|
|
102
122
|
* Unique id for THIS menu panel. The roving-focus queries below target
|
|
@@ -127,8 +147,20 @@
|
|
|
127
147
|
|
|
128
148
|
function openSubmenuAt(el: HTMLElement, index: number) {
|
|
129
149
|
const rect = el.getBoundingClientRect();
|
|
130
|
-
|
|
150
|
+
// The submenu panel is `min-w-[200px]`. When there is no room to the
|
|
151
|
+
// right of the parent item (viewport edge), anchor the submenu at the
|
|
152
|
+
// parent's LEFT edge with placement 'left-start' so the two menus
|
|
153
|
+
// never overlap.
|
|
154
|
+
const SUBMENU_WIDTH = 200;
|
|
155
|
+
let nextPlacement: 'bottom-start' | 'left-start' = 'bottom-start';
|
|
156
|
+
let anchorX = rect.right;
|
|
157
|
+
if (rect.right + SUBMENU_WIDTH > window.innerWidth) {
|
|
158
|
+
anchorX = rect.left;
|
|
159
|
+
nextPlacement = 'left-start';
|
|
160
|
+
}
|
|
161
|
+
submenuX = anchorX;
|
|
131
162
|
submenuY = rect.top;
|
|
163
|
+
submenuPlacement = nextPlacement;
|
|
132
164
|
submenuIndex = index;
|
|
133
165
|
}
|
|
134
166
|
|
|
@@ -335,7 +367,11 @@
|
|
|
335
367
|
$effect(() => {
|
|
336
368
|
if (open && menuEl && autoFocus) {
|
|
337
369
|
requestAnimationFrame(() => {
|
|
370
|
+
// .focus() dispatches the focus event synchronously, so the
|
|
371
|
+
// guard brackets this call exactly.
|
|
372
|
+
suppressFocusOpen = true;
|
|
338
373
|
menuItemButtons()[0]?.focus();
|
|
374
|
+
suppressFocusOpen = false;
|
|
339
375
|
});
|
|
340
376
|
}
|
|
341
377
|
});
|
|
@@ -354,7 +390,7 @@
|
|
|
354
390
|
data-context-menu={menuId}
|
|
355
391
|
class="min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
|
|
356
392
|
style="z-index: {zIndex};"
|
|
357
|
-
use:floating={{ anchor, placement:
|
|
393
|
+
use:floating={{ anchor, placement: placementProp, flip: true, shift: true, offset: 2, onDismiss: close }}
|
|
358
394
|
onkeydown={handleKeydown}
|
|
359
395
|
>
|
|
360
396
|
{#if header}
|
|
@@ -388,6 +424,10 @@
|
|
|
388
424
|
onclick={(e) => handleItemClick(item, i, e)}
|
|
389
425
|
onpointerenter={(e) => handleItemPointerEnter(item, i, e)}
|
|
390
426
|
onfocus={(e) => {
|
|
427
|
+
// A programmatic autofocus (the initial focus when the menu
|
|
428
|
+
// opens) must NOT open the submenu — only user gestures
|
|
429
|
+
// (hover, click, Enter/Space, ArrowRight) do.
|
|
430
|
+
if (suppressFocusOpen) return;
|
|
391
431
|
if (item.submenu?.length && !item.disabled) {
|
|
392
432
|
openSubmenuAt(e.currentTarget as HTMLElement, i);
|
|
393
433
|
} else {
|
|
@@ -427,6 +467,7 @@
|
|
|
427
467
|
open={true}
|
|
428
468
|
x={submenuX}
|
|
429
469
|
y={submenuY}
|
|
470
|
+
placement={submenuPlacement}
|
|
430
471
|
items={items[submenuIndex].submenu ?? []}
|
|
431
472
|
zIndex={zIndex + 1}
|
|
432
473
|
autoFocus={false}
|
|
@@ -21,9 +21,19 @@ interface Props {
|
|
|
21
21
|
* menus; the component sets this to `false` when it recursively renders
|
|
22
22
|
* itself as a nested submenu so that arrow-navigating over a parent item
|
|
23
23
|
* with a submenu doesn't yank keyboard focus out of the parent row.
|
|
24
|
-
*
|
|
24
|
+
* The initial programmatic autofocus does NOT open a submenu that
|
|
25
|
+
* happens to live on the first item — a submenu opens on hover, click,
|
|
26
|
+
* Enter/Space or ArrowRight only. ArrowRight from the parent explicitly
|
|
27
|
+
* focuses into the submenu.
|
|
25
28
|
*/
|
|
26
29
|
autoFocus?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Floating-ui placement for this menu panel. Top-level menus anchor at
|
|
32
|
+
* the cursor and open 'bottom-start'; a nested submenu passes
|
|
33
|
+
* 'left-start' when it must flip to the parent's LEFT edge because
|
|
34
|
+
* there is no room to the right of it.
|
|
35
|
+
*/
|
|
36
|
+
placement?: 'bottom-start' | 'left-start';
|
|
27
37
|
/**
|
|
28
38
|
* Called when an action item (not a submenu owner) is chosen, before
|
|
29
39
|
* this menu closes. A nested submenu uses it to close the WHOLE menu
|
|
@@ -7,8 +7,35 @@
|
|
|
7
7
|
* opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Any other native attribute (aria-label, aria-labelledby, data-*, title,
|
|
14
|
+
* list, inputmode, step, min, max, event handlers…) passes straight through
|
|
15
|
+
* to the <input>. `label` is optional: a compact input with no visible label
|
|
16
|
+
* must pass `aria-label` (or `aria-labelledby`) to get an accessible name.
|
|
17
|
+
*/
|
|
18
|
+
interface Props
|
|
19
|
+
extends Omit<
|
|
20
|
+
HTMLInputAttributes,
|
|
21
|
+
| 'value'
|
|
22
|
+
| 'type'
|
|
23
|
+
| 'size'
|
|
24
|
+
| 'class'
|
|
25
|
+
| 'id'
|
|
26
|
+
| 'name'
|
|
27
|
+
| 'autocomplete'
|
|
28
|
+
| 'placeholder'
|
|
29
|
+
| 'required'
|
|
30
|
+
| 'disabled'
|
|
31
|
+
| 'readonly'
|
|
32
|
+
| 'maxlength'
|
|
33
|
+
| 'minlength'
|
|
34
|
+
| 'pattern'
|
|
35
|
+
| 'children'
|
|
36
|
+
> {
|
|
37
|
+
/** Visible label. Omit it only with `aria-label` / `aria-labelledby`. */
|
|
38
|
+
label?: string;
|
|
12
39
|
value?: string;
|
|
13
40
|
placeholder?: string;
|
|
14
41
|
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
|
@@ -54,7 +81,8 @@
|
|
|
54
81
|
class: className = '',
|
|
55
82
|
id = `text-input-${Math.random().toString(36).substr(2, 9)}`,
|
|
56
83
|
name = '',
|
|
57
|
-
autocomplete = ''
|
|
84
|
+
autocomplete = '',
|
|
85
|
+
...rest
|
|
58
86
|
}: Props = $props();
|
|
59
87
|
|
|
60
88
|
const sizeClass = $derived({ sm: 'input-sm', md: '', lg: 'input-lg' }[size]);
|
|
@@ -97,7 +125,7 @@
|
|
|
97
125
|
</script>
|
|
98
126
|
|
|
99
127
|
<div class="form-control w-full" class:jatui-float-wrap={floatingLabel}>
|
|
100
|
-
{#if !floatingLabel}
|
|
128
|
+
{#if label && !floatingLabel}
|
|
101
129
|
<label for={id} class="label">
|
|
102
130
|
<span class="text-[0.8125rem] font-medium text-base-content/85">
|
|
103
131
|
{label}
|
|
@@ -110,6 +138,7 @@
|
|
|
110
138
|
|
|
111
139
|
<div class:jatui-float-field={floatingLabel}>
|
|
112
140
|
<input
|
|
141
|
+
{...rest}
|
|
113
142
|
bind:this={inputEl}
|
|
114
143
|
{id}
|
|
115
144
|
{name}
|
|
@@ -124,12 +153,16 @@
|
|
|
124
153
|
autocomplete={autocomplete as AutoFill}
|
|
125
154
|
bind:value
|
|
126
155
|
class={inputClass}
|
|
127
|
-
aria-describedby={error
|
|
156
|
+
aria-describedby={error
|
|
157
|
+
? `${id}-error`
|
|
158
|
+
: helpText
|
|
159
|
+
? `${id}-help`
|
|
160
|
+
: rest['aria-describedby']}
|
|
128
161
|
aria-invalid={error ? 'true' : 'false'}
|
|
129
162
|
onanimationend={() => inputEl?.classList.remove('jatui-input-shake')}
|
|
130
163
|
/>
|
|
131
164
|
|
|
132
|
-
{#if floatingLabel}
|
|
165
|
+
{#if label && floatingLabel}
|
|
133
166
|
<label for={id} class="jatui-float-label">
|
|
134
167
|
{label}
|
|
135
168
|
{#if required}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
* TextInput Component
|
|
3
|
+
*
|
|
4
|
+
* Labeled text input with validation, error state, help text, and character count.
|
|
5
|
+
* Adds: motion-token error shake (embedded keyframe, no host-app dependency),
|
|
6
|
+
* opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
|
|
7
|
+
*/
|
|
8
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
9
|
+
/**
|
|
10
|
+
* Any other native attribute (aria-label, aria-labelledby, data-*, title,
|
|
11
|
+
* list, inputmode, step, min, max, event handlers…) passes straight through
|
|
12
|
+
* to the <input>. `label` is optional: a compact input with no visible label
|
|
13
|
+
* must pass `aria-label` (or `aria-labelledby`) to get an accessible name.
|
|
7
14
|
*/
|
|
8
|
-
interface Props {
|
|
9
|
-
label
|
|
15
|
+
interface Props extends Omit<HTMLInputAttributes, 'value' | 'type' | 'size' | 'class' | 'id' | 'name' | 'autocomplete' | 'placeholder' | 'required' | 'disabled' | 'readonly' | 'maxlength' | 'minlength' | 'pattern' | 'children'> {
|
|
16
|
+
/** Visible label. Omit it only with `aria-label` / `aria-labelledby`. */
|
|
17
|
+
label?: string;
|
|
10
18
|
value?: string;
|
|
11
19
|
placeholder?: string;
|
|
12
20
|
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Any other native attribute (aria-label, aria-describedby, data-*, title,
|
|
6
|
+
* required, form…) passes straight through to the <input>. Without a visible
|
|
7
|
+
* `label`, pass `aria-label` so the checkbox has an accessible name.
|
|
8
|
+
*
|
|
9
|
+
* The click target is always at least 24×24px (WCAG 2.5.8), even at
|
|
10
|
+
* size xs/sm: the input sits in a <label> box with a 24px minimum, and a
|
|
11
|
+
* click anywhere in that box toggles it.
|
|
12
|
+
*/
|
|
13
|
+
interface Props
|
|
14
|
+
extends Omit<
|
|
15
|
+
HTMLInputAttributes,
|
|
16
|
+
'type' | 'checked' | 'size' | 'disabled' | 'name' | 'id' | 'onchange' | 'class' | 'children'
|
|
17
|
+
> {
|
|
3
18
|
checked?: boolean;
|
|
4
19
|
label?: string;
|
|
5
20
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
|
@@ -22,7 +37,8 @@
|
|
|
22
37
|
name,
|
|
23
38
|
id,
|
|
24
39
|
onchange,
|
|
25
|
-
class: className = ''
|
|
40
|
+
class: className = '',
|
|
41
|
+
...rest
|
|
26
42
|
}: Props = $props();
|
|
27
43
|
|
|
28
44
|
const sizeClass = $derived({ xs: 'checkbox-xs', sm: 'checkbox-sm', md: '', lg: 'checkbox-lg' }[size]);
|
|
@@ -57,8 +73,9 @@
|
|
|
57
73
|
</script>
|
|
58
74
|
|
|
59
75
|
{#if label}
|
|
60
|
-
<label class="
|
|
76
|
+
<label class="jatui-checkbox-target jatui-checkbox-row" for={inputId}>
|
|
61
77
|
<input
|
|
78
|
+
{...rest}
|
|
62
79
|
bind:this={inputEl}
|
|
63
80
|
type="checkbox"
|
|
64
81
|
{name}
|
|
@@ -71,15 +88,36 @@
|
|
|
71
88
|
<span class="label-text">{label}</span>
|
|
72
89
|
</label>
|
|
73
90
|
{:else}
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
<label class="jatui-checkbox-target jatui-checkbox-bare">
|
|
92
|
+
<input
|
|
93
|
+
{...rest}
|
|
94
|
+
bind:this={inputEl}
|
|
95
|
+
type="checkbox"
|
|
96
|
+
{name}
|
|
97
|
+
{id}
|
|
98
|
+
class={inputClass}
|
|
99
|
+
checked={checked}
|
|
100
|
+
{disabled}
|
|
101
|
+
onchange={handleChange}
|
|
102
|
+
/>
|
|
103
|
+
</label>
|
|
85
104
|
{/if}
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
/* 24px minimum target (WCAG 2.5.8). The visual checkbox keeps its size. */
|
|
108
|
+
.jatui-checkbox-target {
|
|
109
|
+
min-height: 1.5rem;
|
|
110
|
+
align-items: center;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
.jatui-checkbox-row {
|
|
114
|
+
display: flex;
|
|
115
|
+
gap: 0.5rem;
|
|
116
|
+
}
|
|
117
|
+
.jatui-checkbox-bare {
|
|
118
|
+
display: inline-flex;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
min-width: 1.5rem;
|
|
121
|
+
vertical-align: middle;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
2
|
+
/**
|
|
3
|
+
* Any other native attribute (aria-label, aria-describedby, data-*, title,
|
|
4
|
+
* required, form…) passes straight through to the <input>. Without a visible
|
|
5
|
+
* `label`, pass `aria-label` so the checkbox has an accessible name.
|
|
6
|
+
*
|
|
7
|
+
* The click target is always at least 24×24px (WCAG 2.5.8), even at
|
|
8
|
+
* size xs/sm: the input sits in a <label> box with a 24px minimum, and a
|
|
9
|
+
* click anywhere in that box toggles it.
|
|
10
|
+
*/
|
|
11
|
+
interface Props extends Omit<HTMLInputAttributes, 'type' | 'checked' | 'size' | 'disabled' | 'name' | 'id' | 'onchange' | 'class' | 'children'> {
|
|
2
12
|
checked?: boolean;
|
|
3
13
|
label?: string;
|
|
4
14
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|