Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class BaredomSearchField implements OnInit, OnDestroy {
@Input() set autocomplete(v: string) { this.el.autocomplete = v as any; }
@Input() set disabled(v: boolean) { this.el.disabled = v as any; }
@Input() set required(v: boolean) { this.el.required = v as any; }
@Input() set debounce(v: number) { this.el.debounce = v as any; }

@Output() input = new EventEmitter<CustomEvent<{ name: string; value: string }>>();
@Output() change = new EventEmitter<CustomEvent<{ name: string; value: string }>>();
Expand Down
1 change: 1 addition & 0 deletions adapters/react/src/x-search-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface XSearchFieldProps {
autocomplete?: string;
disabled?: boolean;
required?: boolean;
debounce?: number;
onInput?: (e: CustomEvent<{ name: string; value: string }>) => void;
onChange?: (e: CustomEvent<{ name: string; value: string }>) => void;
onSearch?: (e: CustomEvent<{ name: string; value: string }>) => void;
Expand Down
1 change: 1 addition & 0 deletions adapters/solid/src/x-search-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface XSearchFieldProps {
autocomplete?: string;
disabled?: boolean;
required?: boolean;
debounce?: number;
onInput?: (e: CustomEvent<{ name: string; value: string }>) => void;
onChange?: (e: CustomEvent<{ name: string; value: string }>) => void;
onSearch?: (e: CustomEvent<{ name: string; value: string }>) => void;
Expand Down
3 changes: 3 additions & 0 deletions adapters/svelte/src/x-search-field.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
autocomplete?: string;
disabled?: boolean;
required?: boolean;
debounce?: number;
oninput?: (e: CustomEvent<{ name: string; value: string }>) => void;
onchange?: (e: CustomEvent<{ name: string; value: string }>) => void;
onsearch?: (e: CustomEvent<{ name: string; value: string }>) => void;
Expand All @@ -33,6 +34,7 @@
autocomplete,
disabled,
required,
debounce,
oninput,
onchange,
onsearch,
Expand Down Expand Up @@ -73,6 +75,7 @@
{autocomplete}
{disabled}
{required}
{debounce}
class={className}
{id}
{...rest}
Expand Down
1 change: 1 addition & 0 deletions adapters/vue/src/x-search-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const XSearchField = defineComponent({
autocomplete: { type: String as PropType<string | undefined>, default: undefined },
disabled: { type: Boolean as PropType<boolean | undefined>, default: undefined },
required: { type: Boolean as PropType<boolean | undefined>, default: undefined },
debounce: { type: Number as PropType<number | undefined>, default: undefined },
},
emits: {
"input": (_e: CustomEvent<{ name: string; value: string }>) => true,
Expand Down
13 changes: 12 additions & 1 deletion demo/x-search-field.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@
<option value="on">on</option>
</x-select>
</label>
<label>debounce (ms)
<x-form-field type="text" id="ctrl-debounce" value="0"></x-form-field>
</label>
</div>
<p style="font-size:13px;color:#6b7280;margin:0 0 8px;">
Set debounce &gt; 0, then type quickly: local UI (clear button) stays instant, but
<code>x-search-field-input</code> is logged only once you pause.
</p>
<x-search-field id="live-field" name="live" label="Search" placeholder="Search…"></x-search-field>

<!-- ------------------------------------------------------------------ -->
Expand All @@ -96,7 +103,9 @@
<script>
const log = document.getElementById('log');
function logEvent(name, detail) {
log.textContent = `${name} ${JSON.stringify(detail)}\n` + log.textContent;
const t = new Date().toLocaleTimeString([], { hour12: false }) +
'.' + String(new Date().getMilliseconds()).padStart(3, '0');
log.textContent = `${t} ${name} ${JSON.stringify(detail)}\n` + log.textContent;
}

// --- Standalone ---
Expand Down Expand Up @@ -128,10 +137,12 @@
const disabled = document.getElementById('ctrl-disabled').checked;
const required = document.getElementById('ctrl-required').checked;
const autocomplete = document.getElementById('ctrl-autocomplete').value;
const debounce = document.getElementById('ctrl-debounce').value;

liveField.setAttribute('placeholder', placeholder);
liveField.setAttribute('label', label);
liveField.setAttribute('autocomplete', autocomplete);
liveField.setAttribute('debounce', debounce);
if (disabled) liveField.setAttribute('disabled', ''); else liveField.removeAttribute('disabled');
if (required) liveField.setAttribute('required', ''); else liveField.removeAttribute('required');
}
Expand Down
15 changes: 15 additions & 0 deletions docs/x-search-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A form-associated custom element providing search-box UX: inline search icon, cl
| `disabled` | boolean | absent | Disables input, hides clear button |
| `required` | boolean | absent | Marks field as required |
| `autocomplete` | enum | `"off"` | `"on"` \| `"off"` |
| `debounce` | number | `0` | Milliseconds to debounce `x-search-field-input`; `0`/absent/invalid = fire immediately |

## Properties

Expand All @@ -33,6 +34,7 @@ All properties reflect to their corresponding attributes.
| `autocomplete` | string |
| `disabled` | boolean |
| `required` | boolean |
| `debounce` | number |

Reading `value` returns the live `input.value` (not the attribute). Setting `value` syncs both the attribute and the input DOM node.

Expand All @@ -47,6 +49,19 @@ Reading `value` returns the live `input.value` (not the attribute). Setting `val

`x-search-field-search` is dispatched when the user presses Enter. It is cancelable but does **not** auto-submit a parent form.

### Debouncing input

Set the `debounce` attribute (or `.debounce` property) to a positive millisecond count to coalesce rapid typing into a single `x-search-field-input` event, fired once the user pauses. This is opt-in — with `debounce` absent or `0`, the event fires on every keystroke as before.

Only the **outward** `x-search-field-input` event is delayed. Local UI feedback (the clear button, form value, and validity) stays synchronous with each keystroke, so the field never feels laggy. The coalesced event carries the field's **final** value. A pending debounced dispatch is cancelled when the field is cleared, the parent form is reset, or the element is disconnected — so a stale event never lands after the value is gone.

`x-search-field-change` (blur), `x-search-field-search` (Enter), and `x-search-field-clear` are never debounced.

```html
<!-- Fire x-search-field-input at most once per 300ms of quiet typing -->
<x-search-field name="q" placeholder="Search…" debounce="300"></x-search-field>
```

## Shadow DOM Structure

```
Expand Down
21 changes: 17 additions & 4 deletions src/baredom/components/x_search_field/model.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
(def attr-disabled "disabled")
(def attr-required "required")
(def attr-autocomplete "autocomplete")
(def attr-debounce "debounce")

;; Event name constants
(def event-input "x-search-field-input")
Expand All @@ -24,7 +25,8 @@
attr-label
attr-disabled
attr-required
attr-autocomplete])
attr-autocomplete
attr-debounce])

(def allowed-autocomplete #{"on" "off"})

Expand All @@ -33,14 +35,24 @@
raw
"off"))

(defn normalize-debounce
"Parses the raw `debounce` attribute into a non-negative millisecond count.
Absent, non-numeric, or negative input yields 0 (debounce disabled)."
[raw]
(if (string? raw)
(let [n (js/parseFloat raw)]
(if (or (js/isNaN n) (neg? n)) 0 n))
0))

(def property-api
{:value {:type 'string :reflects-attribute attr-value}
:name {:type 'string :reflects-attribute attr-name}
:placeholder {:type 'string :reflects-attribute attr-placeholder}
:label {:type 'string :reflects-attribute attr-label}
:autocomplete {:type 'string :reflects-attribute attr-autocomplete}
:disabled {:type 'boolean :reflects-attribute attr-disabled}
:required {:type 'boolean :reflects-attribute attr-required}})
:required {:type 'boolean :reflects-attribute attr-required}
:debounce {:type 'number :reflects-attribute attr-debounce}})

(def event-schema
{event-input {:cancelable false :detail {:name 'string :value 'string}}
Expand All @@ -51,14 +63,15 @@
(defn normalize
"Derives a complete view-model map from raw attribute values."
[{:keys [name-raw value-raw placeholder-raw label-raw
disabled-present? required-present? autocomplete-raw]}]
disabled-present? required-present? autocomplete-raw debounce-raw]}]
{:name (or name-raw "")
:value (or value-raw "")
:placeholder (or placeholder-raw "")
:label (or label-raw "")
:disabled? (boolean disabled-present?)
:required? (boolean required-present?)
:autocomplete (normalize-autocomplete autocomplete-raw)})
:autocomplete (normalize-autocomplete autocomplete-raw)
:debounce (normalize-debounce debounce-raw)})

(def method-api
{:checkValidity {:args [] :returns 'boolean}
Expand Down
44 changes: 40 additions & 4 deletions src/baredom/components/x_search_field/x_search_field.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
;; ---------------------------------------------------------------------------
;; Instance field keys (Closure-safe: access via gobj/get / gobj/set)
;; ---------------------------------------------------------------------------
(def ^:private k-refs "__xSearchFieldRefs")
(def ^:private k-internals "__xSearchFieldInternals")
(def ^:private k-handlers "__xSearchFieldHandlers")
(def ^:private k-refs "__xSearchFieldRefs")
(def ^:private k-internals "__xSearchFieldInternals")
(def ^:private k-handlers "__xSearchFieldHandlers")
(def ^:private k-debounce-timer "__xSearchFieldDebounceTimer")

;; ---------------------------------------------------------------------------
;; Style
Expand Down Expand Up @@ -227,6 +228,37 @@
;; ---------------------------------------------------------------------------
;; Event handlers
;; ---------------------------------------------------------------------------
;; ---------------------------------------------------------------------------
;; Debounced input dispatch
;; ---------------------------------------------------------------------------
(defn- clear-pending-debounce! [^js el]
(when-let [timer (du/getv el k-debounce-timer)]
(js/clearTimeout timer)
;; Transient handle: debounce timer id — no diagnostic display value.
(du/setv-untraced! el k-debounce-timer nil)))

(defn- dispatch-input! [^js el name value]
(du/dispatch! el model/event-input #js {:name name :value value}))

;; Dispatches `x-search-field-input` either immediately or, when the
;; `debounce` attribute is a positive millisecond count, after the caller
;; stops typing for that long. Only the outward event is delayed — local
;; effects (form value, validity, clear button) already ran synchronously.
(defn- schedule-input-dispatch! [^js el name value]
(let [ms (model/normalize-debounce (du/get-attr el model/attr-debounce))]
(clear-pending-debounce! el)
(if (pos? ms)
(let [timer (js/setTimeout
(fn []
;; Transient handle: debounce timer id.
(du/setv-untraced! el k-debounce-timer nil)
(when-let [refs (du/getv el k-refs)]
(dispatch-input! el name (.-value (gobj/get refs "input")))))
ms)]
;; Transient handle: debounce timer id.
(du/setv-untraced! el k-debounce-timer timer))
(dispatch-input! el name value))))

(defn- make-input-handler [^js el]
(fn [^js _evt]
(when-let [refs (du/getv el k-refs)]
Expand All @@ -238,7 +270,7 @@
(.setFormValue internals value)
(sync-validity! el internals input-el))
(toggle-clear-visibility! input-el clear-el el)
(du/dispatch! el model/event-input #js {:name name :value value})))))
(schedule-input-dispatch! el name value)))))

(defn- make-change-handler [^js el]
(fn [^js _evt]
Expand Down Expand Up @@ -271,6 +303,7 @@
^js clear-el (gobj/get refs "clear")
name (or (du/get-attr el model/attr-name) "")]
(set! (.-value input-el) "")
(clear-pending-debounce! el)
(when-let [^js internals (du/getv el k-internals)]
(.setFormValue internals "")
(sync-validity! el internals input-el))
Expand Down Expand Up @@ -318,6 +351,7 @@
(render! el))

(defn- form-reset! [^js el]
(clear-pending-debounce! el)
(du/remove-attr! el model/attr-value)
(when-let [refs (du/getv el k-refs)]
(let [^js input-el (gobj/get refs "input")]
Expand Down Expand Up @@ -348,6 +382,7 @@
(render! el))

(defn- disconnected! [^js el]
(clear-pending-debounce! el)
(remove-listeners! el))

(defn- attribute-changed! [^js el name _old new-val]
Expand Down Expand Up @@ -390,6 +425,7 @@
(du/define-string-prop! proto "autocomplete" model/attr-autocomplete "")
(du/define-bool-prop! proto "disabled" model/attr-disabled)
(du/define-bool-prop! proto "required" model/attr-required)
(du/define-number-prop! proto "debounce" model/attr-debounce 0)
;; Methods (Tier-2 .defineProperty :value descriptors)
(.defineProperty js/Object proto "checkValidity"
#js {:value (fn xsf-check-validity []
Expand Down
28 changes: 27 additions & 1 deletion test/baredom/components/x_search_field/model_test.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@
(deftest autocomplete-empty-defaults-off-test
(is (= "off" (model/normalize-autocomplete ""))))

;; ---------------------------------------------------------------------------
;; normalize-debounce
;; ---------------------------------------------------------------------------

(deftest debounce-positive-test
(is (= 300 (model/normalize-debounce "300"))))

(deftest debounce-zero-test
(is (= 0 (model/normalize-debounce "0"))))

(deftest debounce-nil-defaults-zero-test
(is (= 0 (model/normalize-debounce nil))))

(deftest debounce-empty-defaults-zero-test
(is (= 0 (model/normalize-debounce ""))))

(deftest debounce-non-numeric-defaults-zero-test
(is (= 0 (model/normalize-debounce "abc"))))

(deftest debounce-negative-defaults-zero-test
(is (= 0 (model/normalize-debounce "-5"))))

;; ---------------------------------------------------------------------------
;; normalize — boolean flags
;; ---------------------------------------------------------------------------
Expand Down Expand Up @@ -53,7 +75,11 @@
(is (= "" (:label m)))
(is (= false (:disabled? m)))
(is (= false (:required? m)))
(is (= "off" (:autocomplete m)))))
(is (= "off" (:autocomplete m)))
(is (= 0 (:debounce m)))))

(deftest normalize-debounce-field-test
(is (= 250 (:debounce (model/normalize {:debounce-raw "250"})))))

;; ---------------------------------------------------------------------------
;; normalize — string fields pass through
Expand Down
Loading