@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.
@@ -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 = 'translateY(0)';
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
- * ArrowRight from the parent explicitly focuses into the submenu.
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
- submenuX = rect.right;
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: 'bottom-start', flip: true, shift: true, offset: 2, onDismiss: close }}
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
- * ArrowRight from the parent explicitly focuses into the submenu.
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
- interface Props {
11
- label: string;
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 ? `${id}-error` : helpText ? `${id}-help` : undefined}
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
- * 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).
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: string;
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
- interface Props {
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="flex cursor-pointer items-center gap-2" for={inputId}>
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
- <input
75
- bind:this={inputEl}
76
- type="checkbox"
77
- {name}
78
- {id}
79
- class={inputClass}
80
- checked={checked}
81
- {disabled}
82
- onchange={handleChange}
83
- aria-label={label}
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
- interface Props {
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",