Skip to content

feat!: attribute reduction — client-side open/close + template migration#292

Merged
adnaan merged 17 commits intomainfrom
client-side-openclose
Apr 5, 2026
Merged

feat!: attribute reduction — client-side open/close + template migration#292
adnaan merged 17 commits intomainfrom
client-side-openclose

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 5, 2026

Summary

  • Component open/close refactor: Moved 7 components (dropdown, menu, popover, drawer, datepicker, timepicker, autocomplete) from server-managed Open state to client-side CSS class toggling. Eliminates server round-trips for UI-only concerns.
  • Phase 2A attribute migration: All component, generator, and kit templates updated to new lvt-on:{event}, lvt-mod:, lvt-form: syntax. 76 template files, perfectly balanced rename.
  • Phase 2B e2e test updates: All chromedp selectors and inline HTML in 12 e2e test files updated.

Key changes

  • lvt-click on buttons → name (Tier 1 routing)
  • lvt-click on non-buttons → lvt-on:click (Tier 2 generic router)
  • lvt-data-*data-* (standard HTML)
  • lvt-submit → form/button name routing
  • lvt-modal-open/closecommand/commandfor (native dialog)
  • lvt-confirmonclick="return confirm(...)"
  • lvt-debouncelvt-mod:debounce
  • lvt-disable-withlvt-form:disable-with
  • lvt-click-awaylvt-el:removeClass:on:click-away="open" (client-side CSS)
  • Toggle/Close/Show/Hide server methods removed from 7 components

Test plan

  • All 24 component test packages pass
  • All root module tests pass (golden files regenerated)
  • Generator and kit parity tests pass
  • Zero deprecated lvt-* attributes in any template
  • E2E browser tests (require published client v0.8.17+)

🤖 Generated with Claude Code

adnaan and others added 3 commits April 5, 2026 01:58
Move dropdown, menu, popover, drawer, datepicker, timepicker, and
autocomplete open/close from server-managed state to client-side CSS
class toggling. This eliminates server round-trips for UI-only concerns
like dropdown visibility, click-away closing, and toggle toggling.

Pattern:
- Root element gets lvt-el:removeClass:on:click-away="open"
- Trigger button uses onclick JS to toggle "open" class
- Panel is always rendered, hidden via CSS when root lacks "open"
- Server actions only handle data operations (Select, Search, etc.)
- Multi-select uses lvt-el:addClass:on:done="open" to stay open

Removed: Toggle(), Close(), Show(), Hide() methods and Open bool fields
from dropdown, menu, popover, drawer, datepicker, and timepicker.
Kept: autocomplete Open field (data-dependent visibility).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… syntax

BREAKING CHANGE: All component, generator, and kit templates updated:
- lvt-click on buttons → name (Tier 1 button routing)
- lvt-click on non-buttons → lvt-on:click (Tier 2 generic router)
- lvt-data-* → data-* (standard HTML)
- lvt-input → lvt-on:input, lvt-keydown → lvt-on:keydown, etc.
- lvt-change → lvt-on:change
- lvt-debounce → lvt-mod:debounce
- lvt-submit → form/button name routing
- lvt-modal-open/close → command/commandfor (native dialog)
- lvt-confirm → onclick="return confirm(...)"
- lvt-disable-with → lvt-form:disable-with
- JS querySelector selectors updated for new attribute names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update all chromedp selectors and inline HTML templates in e2e tests:
- button[lvt-click="X"] → button[name="X"]
- form[lvt-submit="X"] → form[name="X"]
- [lvt-modal-open="X"] → [commandfor="X"]
- lvt-modal-close → command="close" commandfor
- getAttribute('lvt-data-id') → getAttribute('data-id')
- Inline HTML: lvt-click → name, lvt-data-* → data-*

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 5, 2026 02:38
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

This is a well-scoped, coherent refactor. The architectural direction (client-side open/close, standard HTML attributes) is sound and the mechanical renaming is thorough. A few things worth addressing:


Bug: hidden attribute in datepicker single template

components/datepicker/templates/single.tmpl adds hidden to the panel div:

<div
  class="{{.Styles.Dropdown}}"
  data-datepicker-panel
  hidden
  ...

But show/hide is controlled by toggling the open CSS class on the parent — the rule [data-datepicker]:not(.open) [data-datepicker-panel]{display:none} only covers the hide case. There is no rule to override hidden (i.e., no [data-datepicker].open [data-datepicker-panel]{display:block} or equivalent). The hidden attribute wins and the panel will never be visible when clicking the trigger.

The range template (range.tmpl) doesn't have this hidden attribute — so it's an inconsistency, and the single picker is likely broken. Either remove hidden and rely solely on the CSS, or add an explicit show rule.


Stale docstring in timepicker.go

The comment was only partially updated:

// Required lvt-* attributes: lvt-click, lvt-input, lvt-el:removeClass:on:click-away, lvt-el:addClass:on:done

lvt-click and lvt-input are old syntax. Looking at the template, these are now onclick=... (not a framework attribute at all) and lvt-on:input. The docstring should reflect the actual attributes used.


CSP incompatibility from inline event handlers

The migration from lvt-click="toggle_X" to onclick="var r=this.closest(...)..." introduces inline JavaScript that breaks under Content-Security-Policy: script-src 'self' (no unsafe-inline). The old lvt-* attributes were declarative and processed by the framework's script, so they were CSP-safe. This is a regression for any apps with a strict CSP.

Consider encoding toggle behavior as a new framework directive (e.g., lvt-toggle="open") rather than raw inline JS, which would also be more consistent with the lvt-el: syntax already used for click-away.


Duplicate <style> injection

Each component template emits its own <style> block. Multiple instances of the same component on a page produce duplicate identical rules — e.g., 3 dropdowns → 3 copies of [data-dropdown]:not(.open) [data-dropdown-panel]{display:none}. Browsers handle this fine, but it's noisy. If these rules can't move to the client library's stylesheet, at least document this as a known trade-off.


aria-expanded hardcoded to "false"

Trigger buttons now have aria-expanded="false" hardcoded, with the JS onclick updating it client-side. If the server re-renders a component (e.g., after a date selection or validation), aria-expanded will reset to "false" even if the panel is open client-side. Screen readers will get incorrect information after any server round-trip that re-renders the component.

This is a fundamental tension in the model — worth either accepting consciously or mitigating by having the server reflect open state via a CSS class and using aria-expanded="{{if $c.Open}}true{{else}}false{{end}}" (which requires keeping some server state).


lvt-el:addClass:on:done="open" on datepicker/timepicker may cause unexpected reopening

Adding open on every server response (on:done) means that any server-triggered re-render of the datepicker (e.g., updating a nearby field) will reopen the calendar popup. Is the intent that the picker should always be open after a selection? Dropdown and menu components don't have this attribute, so the behaviour is inconsistent. If it's intentional for datepicker, it should be documented.


Minor

  • The aria-controls fix (always rendered, not gated on {{if $c.Open}}) is correct per the ARIA spec. Good change.
  • Removing Focus()/Blur()/Toggle()/Close() from the 7 components is a clean simplification — the deleted tests were testing the right things for the old model.
  • The e2e test suite is gated on client v0.8.17+ (noted in PR). Worth a follow-up PR or issue to track that dependency.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates LiveTemplate templates and tests to the new attribute syntax while refactoring several UI components to manage open/close entirely client-side (removing server-managed Open state and related server methods).

Changes:

  • Migrated templates from deprecated lvt-* attributes to name, lvt-on:{event}, lvt-mod:*, and lvt-form:* conventions across generators, kits, and components.
  • Refactored open/close behavior for multiple components (dropdown/menu/popover/drawer/datepicker/timepicker/autocomplete) to rely on client-side class toggling rather than server state.
  • Updated E2E and parity tests to use new selectors/attributes.

Reviewed changes

Copilot reviewed 108 out of 108 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
testdata/golden/resource_template.tmpl.golden Golden output updated for new attributes
internal/kits/system/single/templates/resource/template.tmpl.tmpl Single kit resource template migration
internal/kits/system/single/templates/resource/embedded_template.tmpl.tmpl Embedded resource template migration
internal/kits/system/single/components/toolbar.tmpl Toolbar attribute migration
internal/kits/system/single/components/table.tmpl Table action routing migration
internal/kits/system/single/components/sort.tmpl Sort control migration
internal/kits/system/single/components/search.tmpl Search control migration
internal/kits/system/single/components/pagination.tmpl Pagination routing migration
internal/kits/system/single/components/layout.tmpl Layout JS selector updates
internal/kits/system/single/components/form.tmpl Form submission/routing migration
internal/kits/system/single/components/detail.tmpl Detail actions migration
internal/kits/system/simple/templates/app/index.tmpl.tmpl Simple kit button routing migration
internal/kits/system/simple/components/layout.tmpl Layout JS selector updates
internal/kits/system/multi/templates/resource/template.tmpl.tmpl Multi kit resource template migration
internal/kits/system/multi/templates/resource/embedded_template.tmpl.tmpl Embedded resource template migration
internal/kits/system/multi/templates/auth/template.tmpl.tmpl Auth template migration
internal/kits/system/multi/templates/auth/e2e_test.go.tmpl Auth E2E template selector updates
internal/kits/system/multi/components/toolbar.tmpl Toolbar attribute migration
internal/kits/system/multi/components/table.tmpl Table action routing migration
internal/kits/system/multi/components/sort.tmpl Sort control migration
internal/kits/system/multi/components/search.tmpl Search control migration
internal/kits/system/multi/components/pagination.tmpl Pagination routing migration
internal/kits/system/multi/components/layout.tmpl Layout JS selector updates
internal/kits/system/multi/components/form.tmpl Form submission/routing migration
internal/kits/system/multi/components/detail.tmpl Detail actions migration
internal/kits/kits_parity_test.go Parity patterns updated for new attrs
internal/generator/templates/resource/template.tmpl.tmpl Resource generator template migration
internal/generator/templates/components/toolbar.tmpl Generator toolbar migration
internal/generator/templates/components/table.tmpl Generator table migration
internal/generator/templates/components/sort.tmpl Generator sort migration
internal/generator/templates/components/search.tmpl Generator search migration
internal/generator/templates/components/pagination.tmpl Generator pagination migration
internal/generator/templates/components/layout.tmpl Generator layout JS selector update
internal/generator/templates/components/form.tmpl Generator form routing migration
internal/generator/templates/components/detail.tmpl Generator detail routing migration
internal/generator/embedded_resource_test.go Embedded generation assertions updated
e2e/wire_format_test.go Wire-format HTML updated to new attrs
e2e/tutorial_test.go Tutorial E2E selectors updated
e2e/resource_generation_test.go Resource-gen E2E assertions updated
e2e/rendering_test.go Rendering E2E updated for new modal attrs
e2e/pagemode_test.go Page-mode E2E selectors updated
e2e/modal_test.go Modal E2E HTML updated for new attrs
e2e/livetemplate_core_test.go Core E2E HTML updated for new attrs
e2e/embedded_browser_test.go Embedded-browser E2E selectors updated
e2e/editmode_test.go Edit-mode regex/assertions updated
e2e/edit_modal_reopen_test.go Edit-modal E2E selectors updated
e2e/delete_multi_post_test.go Delete-multi-post E2E selectors updated
e2e/complete_workflow_test.go Full workflow E2E selectors updated
components/tooltip/templates/default.tmpl Tooltip event attr migration
components/toggle/toggle.go Toggle docs updated for new attrs
components/toggle/templates/default.tmpl Toggle template event attr migration
components/toggle/templates/checkbox.tmpl Toggle checkbox template migration
components/toast/toast.go Toast docs updated for new routing
components/timepicker/timepicker.go Timepicker server open/close removal
components/timepicker/timepicker_test.go Timepicker tests updated/removed for Open
components/timepicker/templates/duration.tmpl Duration picker template client open/close
components/timepicker/templates/default.tmpl Time picker template client open/close
components/timepicker/options.go WithOpen made no-op (compat)
components/tagsinput/templates/default.tmpl Tagsinput event/routing migration
components/tagsinput/tagsinput.go Tagsinput docs updated
components/tagsinput/tagsinput_test.go Tagsinput template assertions updated
components/tabs/templates/vertical.tmpl Tabs routing migration
components/tabs/templates/pills.tmpl Tabs routing migration
components/tabs/templates/horizontal.tmpl Tabs routing migration
components/tabs/tabs.go Tabs docs updated
components/tabs/tabs_test.go Tabs template assertions updated
components/rating/templates/default.tmpl Rating event attr migration
components/rating/rating.go Rating docs updated
components/popover/templates/default.tmpl Popover client open/close migration
components/popover/popover.go Popover server Open removed + docs
components/popover/popover_test.go Popover tests updated for removed Open
components/popover/options.go WithOpen made no-op (compat)
components/modal/templates/sheet.tmpl Modal sheet template routing migration
components/modal/templates/default.tmpl Modal template routing migration
components/modal/templates/confirm.tmpl Confirm modal button routing migration
components/modal/modal.go Modal docs updated re client open/close
components/menu/templates/nav.tmpl Nav menu open/close client-side migration
components/menu/templates/default.tmpl Menu open/close client-side migration
components/menu/templates/context.tmpl Context menu template updated for new attrs
components/menu/options.go WithOpen made no-op (compat)
components/menu/menu.go Menu server Open removed + method changes
components/menu/menu_test.go Menu tests updated for removed Open
components/dropdown/templates/searchable.tmpl Searchable dropdown migration + open class
components/dropdown/templates/multi.tmpl Multi dropdown migration + client open/close
components/dropdown/templates/default.tmpl Default dropdown migration + client open/close
components/dropdown/options.go WithOpen deprecated/no-op
components/dropdown/dropdown.go Dropdown server Open removal + searchable Open
components/dropdown/dropdown_test.go Dropdown tests updated for new behavior
components/drawer/templates/default.tmpl Drawer client open/close migration
components/drawer/options.go WithOpen deprecated/no-op
components/drawer/drawer.go Drawer Open removed + transform refactor
components/drawer/drawer_test.go Drawer tests updated for new transform
components/datepicker/templates/single.tmpl Datepicker single template migration
components/datepicker/templates/range.tmpl Datepicker range template migration
components/datepicker/options.go WithOpen made no-op (compat)
components/datepicker/datepicker.go Datepicker Open removed + methods removed
components/datepicker/datepicker_test.go Datepicker tests updated for removed Open
components/datatable/templates/default.tmpl Datatable attribute/routing migration
components/datatable/datatable.go Datatable docs updated
components/base/action.go Data-* doc updates (lvt-data-*data-*)
components/autocomplete/templates/multi.tmpl Autocomplete multi template migration
components/autocomplete/templates/default.tmpl Autocomplete default template migration
components/autocomplete/autocomplete.go Autocomplete Focus/Blur methods removed
components/autocomplete/autocomplete_test.go Autocomplete tests updated for removals
components/accordion/templates/single.tmpl Accordion routing migration
components/accordion/templates/default.tmpl Accordion routing migration
components/accordion/accordion.go Accordion docs updated
components/accordion/accordion_test.go Accordion template assertions updated

Comment on lines 21 to 27
<div
class="{{.Styles.Dropdown}}"
lvt-click-away="close_{{.ID}}"
data-timepicker-panel
hidden
role="dialog"
aria-modal="true"
aria-label="Choose time"
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-timepicker-panel is always rendered with the hidden attribute, but the open/close logic now only toggles the .open class. Since there is no code removing hidden (and the CSS only adds a rule for the closed state), the panel will remain non-rendered even when .open is present. Remove the hidden attribute or toggle it in the same handler that toggles the .open class.

Copilot uses AI. Check for mistakes.
Comment on lines 21 to 27
<div
class="{{.DurationStyles.Dropdown}}"
lvt-click-away="close_{{.ID}}"
data-timepicker-panel
hidden
role="dialog"
aria-modal="true"
aria-label="Choose duration"
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the default timepicker: the duration panel includes hidden but open/close is now driven by toggling the .open class only, so the panel will never become visible. Remove hidden or ensure it is toggled alongside the .open class.

Copilot uses AI. Check for mistakes.
class="{{.Styles.Dropdown}}"
lvt-click-away="close_datepicker_{{.ID}}"
data-datepicker-panel
hidden
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The datepicker dropdown is rendered with the hidden attribute, but the trigger now only toggles the .open class on the root. Without also removing/toggling hidden, the panel will stay display:none even when open. Either remove hidden and rely on the .open CSS, or update the click handler to manage hidden as well.

Suggested change
hidden

Copilot uses AI. Check for mistakes.
class="{{.Styles.Dropdown}}"
lvt-click-away="close_datepicker_{{.ID}}"
data-datepicker-panel
hidden
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The range datepicker dropdown is always rendered with hidden, but visibility is now controlled via .open class toggling. Since hidden is never removed/toggled, the panel will not appear. Remove hidden or toggle it when opening/closing.

Suggested change
hidden

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 17
{{if .IsFocusTrigger}}
lvt-focus="show_{{.ID}}"
lvt-blur="hide_{{.ID}}"
onfocus="this.closest('[data-popover]').classList.add('open')"
onblur="this.closest('[data-popover]').classList.remove('open')"
{{end}}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using onfocus/onblur on this wrapper <div> will not reliably open/close the popover when a descendant (the actual trigger element) receives focus, because native focus/blur do not bubble. Use onfocusin/onfocusout (which do bubble) or move the handlers onto the actual focusable trigger element.

Copilot uses AI. Check for mistakes.
Comment on lines 4 to 8
// - New() creates a time picker (template: "lvt:timepicker:default:v1")
// - NewDuration() creates a duration picker (template: "lvt:timepicker:duration:v1")
//
// Required lvt-* attributes: lvt-click, lvt-input, lvt-click-away
// Required lvt-* attributes: lvt-click, lvt-input, lvt-el:removeClass:on:click-away, lvt-el:addClass:on:done
//
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The top-level docs still list lvt-click/lvt-input as required attributes, but the templates now use inline onclick on the trigger and name="..." routing for the action buttons (plus lvt-el:* modifiers). Please update this comment to reflect the current required attributes/behavior so consumers aren’t misled.

Copilot uses AI. Check for mistakes.
Comment on lines +465 to +472
<button id="open-modal" command="show-modal" commandfor="test-modal">Open Modal</button>

<div id="test-modal" hidden aria-hidden="true" role="dialog" data-modal-backdrop data-modal-id="test-modal"
style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div style="background: white; padding: 2rem; border-radius: 8px;">
<h2>Test Modal</h2>
<p id="modal-content">Modal content here</p>
<button id="close-modal" lvt-modal-close="test-modal">Close</button>
<button id="close-modal" command="close" commandfor="test-modal">Close</button>
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions migrating lvt-modal-open/close to command/commandfor as a native dialog mechanism, but this test (and the embedded markup) still uses a <div role="dialog"> with hidden + style.display === 'flex' assertions. If the intent is native <dialog>, the markup/assertions should be updated; if the intent is a LiveTemplate-specific command router for non-<dialog> elements, the PR description should be clarified to avoid implying browser-native dialog behavior.

Copilot uses AI. Check for mistakes.
…ardize docs

- Extract shared panel CSS rules into _shared.tmpl for dropdown,
  autocomplete, timepicker, and datepicker (deduplicates across variants)
- Remove stale `hidden` attr from datepicker/timepicker panels that
  conflicted with CSS visibility pattern (bug: panels wouldn't show)
- Standardize WithOpen() deprecated doc comments across all components
- Remove duplicate "client-side" comments from struct-level docs
- Fix stale doc comments in timepicker.go and tooltip.go
- Remove orphan "methods removed" comments from test files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

This is a well-executed, internally consistent refactor. The mechanical renaming is thorough and the diff is a net simplification (~660 additions vs. 1156 deletions). A few concerns worth addressing:


Medium: aria-expanded hardcoded to "false" after server re-renders

All trigger buttons in the client-side open/close components (dropdown, menu, popover, datepicker, timepicker, drawer) now have aria-expanded="false" hardcoded. The inline onclick JS updates this attribute on click, but any server-triggered partial re-render will reset aria-expanded to "false" even if the panel is still visually open.

Affected templates: dropdown/default.tmpl, dropdown/searchable.tmpl, dropdown/multi.tmpl, menu/default.tmpl, menu/nav.tmpl, datepicker/single.tmpl, datepicker/range.tmpl, timepicker/default.tmpl, timepicker/duration.tmpl, popover/default.tmpl.

Screen readers will report the wrong state after any server round-trip. This should be tracked as a follow-up accessibility issue if not addressable in this PR.


Medium: lvt-el:addClass:on:done="open" inconsistency between datepicker variants

datepicker/range.tmpl, timepicker/default.tmpl, and timepicker/duration.tmpl include lvt-el:addClass:on:done="open" on the root element, but datepicker/single.tmpl does not. This means:

  • Range datepicker + both timepickers will reopen on every server response, even if the user manually closed them.
  • Single datepicker won't — which may cause a disruptive close during multi-step interactions.

Is the omission on datepicker/single.tmpl intentional? If so, it's worth a comment explaining the difference.


Medium: Inline onclick handlers break strict CSP

The migration from lvt-click="toggle_X" (processed by the framework script) to raw onclick="..." attributes is incompatible with Content-Security-Policy: script-src 'self' without 'unsafe-inline'. The old lvt-* attributes were CSP-safe because they were parsed by the client library. This is a regression for users with a strict CSP.

Worth a note in the migration guide / changelog.


Low: NewInline datepicker silently changed behavior

NewInline() previously set Open = true, rendering the calendar panel immediately. With Open removed and the panel hidden via the new CSS (:not(.open) rule), NewInline() now renders with the panel hidden — the user must click the trigger to open it. The comment "NewInline() creates an inline calendar (always visible)" is now incorrect.

The test validating Open=true was deleted rather than replaced with an assertion about initial panel visibility, so this behavior change is untested.


Low: Duplicate <style> tags on multi-instance pages

Each _shared.tmpl emits an inline <style> block. Three dropdown instances on one page = three identical <style> tags. Browsers handle this correctly, but it's unnecessary HTML bloat worth noting in docs or as a known trade-off.


Low: Stale docstring in timepicker.go

The updated docstring still reads "Required lvt-* attributes: name, ..." — but name is a plain HTML attribute, not an lvt-* attribute. The list also doesn't reflect the onclick="..." trigger used in the actual template.


Test Coverage

The unit-test deletions for removed methods (Toggle, Show, Close, ForceClose, etc.) are appropriate. WithOpen no-op smoke tests are a reasonable backward-compatibility gesture. E2E tests are correctly updated but gated on client v0.8.17+.


Overall this is ready to land with the above items either fixed or tracked. The CSP note in the changelog and the NewInline docstring fix feel like quick wins before merge.

- Use onfocusin/onfocusout instead of onfocus/onblur on popover wrapper
  (focus/blur don't bubble to parent elements)
- Fix timepicker.go doc comment (was listing HTML attrs as lvt-* attrs)
- Update NewInline() doc to note consuming template must add "open" class

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review (additive)

The refactor is well-executed. Two prior reviews cover the main concerns well — this adds two issues not yet mentioned.


Bug: Autocomplete panel stays open after suggestion selection

Previously, selecting a suggestion called Select() which cleared FilteredSuggestions and set Open = false — closing the panel via server state.

With the refactor, Open is removed from Autocomplete and panel visibility is controlled by the client-side open CSS class. But nothing removes that class after a selection:

  • The suggestion <li> uses lvt-on:click="select_ID" (Tier 2 router, non-button)
  • The server re-renders but cannot modify client-side CSS class state
  • There is no lvt-el:removeClass:on:done="open" on the autocomplete root

Result: after clicking a suggestion, the (now-empty) suggestion list stays visible until the user clicks outside. The same applies to keyboard selection via keydown_X. This is a functional regression.

Fix options:

  • Add lvt-el:removeClass:on:done="open" to the autocomplete root wrapper
  • Or re-add a server-controlled Open field (as done for Searchable dropdown)

UX regression: No focusout handler in autocomplete

Previously lvt-blur="blur_ID" called Blur(), which closed the panel on tab-away. Now there is no equivalent — the panel only closes via lvt-el:removeClass:on:click-away="open", which does not fire on keyboard Tab navigation.

A keyboard user tabbing out of the autocomplete input will leave the suggestion dropdown open.

Fix: add onfocusout to the input, guarding against focus moving into the suggestion list:

onfocusout="if(!this.closest('[data-autocomplete]').contains(event.relatedTarget))this.closest('[data-autocomplete]').classList.remove('open')"

Everything else in the prior reviews still stands.

Without onfocusout, tabbing away from the autocomplete input left the
suggestion dropdown open (click-away only fires on mouse clicks, not
keyboard navigation). The handler guards against focus moving into the
suggestion list itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: feat! — Attribute Reduction, Client-Side Open/Close + Template Migration

This is a well-structured, coherent breaking change with a clear architectural direction. The attribute renaming is thorough, internally consistent, and the test coverage updates are complete for unit tests. Several issues are worth addressing before merging.


Bugs / Correctness Issues

1. Persistent drawer Close() behavior lost without replacement

The old Close() respected d.Persistent — it was a no-op if the drawer was persistent. ForceClose() bypassed this. Both methods are now removed in favor of inline onclick JavaScript:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This inline handler does preserve the persistent check — but only for the close button and overlay. There is no replacement for ForceClose() for application code that previously called drawer.ForceClose() in an action handler. If an app needs to programmatically force-close a persistent drawer from server-side logic after an action, there is now no mechanism to do so. The deprecation path should be documented.

2. aria-expanded resets to "false" on server re-render

All toggled components now hardcode aria-expanded="false" on the trigger button, relying on the inline onclick to flip it dynamically. This works correctly for the initial interaction. However, when the server re-renders a component (e.g., a dropdown after Select(), a timepicker after SetTime()), the re-rendered HTML will reset aria-expanded back to "false" even if the panel is currently open. Screen readers will be told the control is collapsed when it is actually expanded. This is a regression for accessibility.

3. Searchable dropdown Open field inconsistency

The Open field was removed from Dropdown but intentionally re-added to Searchable:

// Open is server-controlled for searchable dropdowns because visibility
// depends on whether the query meets MinChars and results exist.
Open bool

This is a principled design choice, but the template renders the panel unconditionally (no {{if $c.Open}}), always relying on the CSS display:none rule. The Open field only adds the open CSS class to the root element via {{if $c.Open}}open{{end}}. If Open is false server-side but the user has typed enough to trigger results, the panel won't show. The consistency between this and the pure client-side approach for the standard dropdown should be verified.

4. lvt-el:addClass:on:done="open" on timepicker/datepicker may reopen on unrelated updates

datepicker/range.tmpl, datepicker/single.tmpl, timepicker/default.tmpl, and timepicker/duration.tmpl all include lvt-el:addClass:on:done="open". This means any server-triggered re-render of the component will add the open class — not just a render triggered by an action within the picker. If another action on the page causes the picker component to re-render indirectly, the calendar/time panel will unexpectedly open. If this is intentional, it should be documented explicitly.

5. Context menu ShowAt() no longer opens the menu

ShowAt(x, y) previously set coordinates and set Open = true. Now it only sets coordinates:

// ShowAt sets the context menu position.
// The consuming page is responsible for adding the "open" CSS class to show it.
func (cm *ContextMenu) ShowAt(x, y int) {
    cm.X = x
    cm.Y = y
}

Any existing application code calling ShowAt() from an action handler will have the position updated but the menu will not open. This is a silent breaking change — the comment says "consuming page is responsible," but for a context menu triggered by a right-click action handler, the server needs a mechanism to signal "open this menu." Suggest adding an Open bool field back for ContextMenu specifically, or documenting the expected pattern.


Design / API Concerns

6. WithOpen(bool) deprecated as a no-op without semver signal

WithOpen(true) is silently converted to a no-op across Dropdown, Menu, Drawer, Popover, Datepicker, Timepicker. Application code that previously used WithOpen(true) to initialize a component in an open state (e.g., a tutorial/walkthrough) will silently stop working. Consider either keeping the functional option (setting an Open bool on the struct which the template renders as a class) or providing a clear migration path for pre-opened components.

7. Inline event handlers and Content-Security-Policy

The migration from lvt-click="toggle_X" to onclick="var r=this.closest(...)..." introduces inline JavaScript throughout. The old lvt-* declarative attributes were processed by the framework's client script and did not require unsafe-inline in a CSP. The new patterns require 'unsafe-inline' in script-src. Any applications with a strict CSP will break silently at runtime. Consider a new framework directive like lvt-toggle:class="open" consistent with the existing lvt-el:removeClass:on:click-away pattern, which would be processed by the client script and remain CSP-safe.

8. Duplicate <style> blocks per component instance

Each template now injects its own <style> block:

<style>[data-dropdown]:not(.open) [data-dropdown-panel]{display:none}</style>

A page with three dropdowns will have three identical <style> blocks in the DOM. This is harmless functionally but creates noise. If these rules cannot be moved to the client library stylesheet (v0.8.17+), consider documenting this as a known trade-off.

9. NewInline() for datepicker no longer always shows the calendar

The old NewInline() set Open = true, making the calendar permanently visible. The new implementation removes the Open field and the {{if}} guard, but there is no code path that sets open by default for NewInline(). An inline calendar created with NewInline() will be closed by default and only open when the trigger button is clicked — defeating the purpose of an inline calendar. The template for inline usage should add open to the root element's class unconditionally.


Code Quality

10. Toggle icon CSS class lost for menu/dropdown

The trigger button SVG chevron previously applied an open-state style:

<svg class="{{$c.Styles.TriggerIcon}} {{if $c.Open}}{{$c.Styles.TriggerIconOpen}}{{end}}"

Now it's unconditional:

<svg class="{{$c.Styles.TriggerIcon}}"

The chevron no longer rotates to indicate open state via server-side class. If the styled kit previously used a Tailwind class like rotate-180 on the icon, that styling is now lost unless the kit handles it via a CSS rule on the parent open class. Please verify.


Test Coverage

11. E2E tests gated on unpublished client

The test plan notes E2E browser tests require published client v0.8.17+. The lvt-el:removeClass:on:click-away, lvt-el:addClass:on:done, and lvt-on:* routing are entirely untested by server-side unit tests. This PR should not be considered fully validated until e2e tests pass. Recommend a follow-up issue to track the client v0.8.17+ publish gate.

12. No test for NewInline() rendering with open class

The existing TestNewInline test previously verified Open = true. The new test only checks that the function doesn't panic. There is no test that the rendered HTML for NewInline() contains the open class or equivalent visible-by-default behavior (related to issue #9 above).


Positive Observations

  • The lvt-data-*data-* migration is strictly correct — standard HTML data-* attributes are the right choice here.
  • The aria-controls fix (always rendered, not gated on {{if $c.Open}}) is correct per the ARIA spec and a good accessibility improvement.
  • Removing Focus()/Blur()/Toggle()/Close() from seven components appropriately reduces API surface.
  • The command/commandfor adoption for modal open/close uses the emerging native HTML API — forward-looking choice.
  • Inline CSS via CSS custom properties for drawer animation (--drawer-closed-transform) is a clean pattern.

Summary

The architectural direction is sound and the mechanical renaming is thorough. Before merging, the most important items to address are:

  1. Bugs: NewInline() calendar not visible by default (feat: add components library CLI support #9), ContextMenu.ShowAt() no longer opens the menu (feat(lvt): redesign gen auth for LiveTemplate v0.5.1 #5), aria-expanded accessibility regression on re-render (docs: Verify CI is working on main #2)
  2. Silent breaking changes: WithOpen(true) no-op with no migration path (feat(lvt): migrate templates to method dispatch pattern #6), CSP regression from inline handlers (feat!: update templates for Controller+State pattern #7)
  3. Test gap: E2E tests blocked on unpublished client (chore: remove temporary livetemplate replace directive #11)

🤖 Generated with Claude Code

Replace onfocus/onfocusout/onmouseenter/onmouseleave with declarative
lvt-el: attributes on autocomplete, searchable dropdown, and popover.

Trigger buttons that toggle parent open state keep onclick for now —
lvt-el: operates on self, not parent elements. A future client library
enhancement (target selector) will address this.

Requires: @livetemplate/client with DOM event trigger support (PR #49)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review — PR #292: Attribute Reduction, Client-Side Open/Close + Template Migration

This is a well-executed, architecturally sound breaking change. The mechanical renaming is thorough and consistent across all 108 changed files, and the net diff (-1161/+669 lines) shows real simplification. Several issues need to be addressed before merging.


Bugs

1. NewInline() datepicker no longer renders the calendar visible

NewInline() previously set Open = true. With Open removed and the panel hidden via [data-datepicker]:not(.open) [data-datepicker-panel]{display:none}, NewInline() now renders with the calendar hidden. No code path sets the open class on the root element for inline instances. The docstring says "The consuming template should add the 'open' class" but the built-in template does not do this.

Fix: add open to the root element's class unconditionally in the inline template variant, or add an AlwaysOpen bool field the template checks.

The deleted TestNewInline (which verified Open=true) was replaced with a no-panic smoke test, leaving the behavior change untested.

2. Autocomplete panel does not close after suggestion selection

Previously Select() set Open = false. Now Open is removed and the panel is controlled by the client-side open CSS class, but nothing removes that class after selection — lvt-on:click="select_ID" triggers a server re-render that cannot modify client-side CSS class state, and there is no lvt-el:removeClass:on:done="open" on the autocomplete root.

After clicking a suggestion, the empty list will remain open until the user clicks outside. This is a functional regression.

Fix: add lvt-el:removeClass:on:done="open" to the root div in both default.tmpl and multi.tmpl.

3. ContextMenu.ShowAt() no longer opens the context menu

ShowAt(x, y) previously set coordinates and set Open = true. Now it only sets coordinates. Application code calling ShowAt() from an action handler will update the position but the menu will silently remain closed.

Suggest adding Open bool back on ContextMenu specifically (the only component where server-side show/hide is necessary at action time), or documenting the expected pattern with a working example.

4. Tab-away closes autocomplete while user is selecting a suggestion

lvt-el:removeClass:on:focusout="open" fires whenever focus leaves any element inside the component — including when focus moves from the input to a suggestion item via keyboard Tab. This incorrectly closes the dropdown mid-selection.

The old Blur() approach handled this implicitly. Consider onfocusout with a relatedTarget check, or a small delay before hiding.


Silent Breaking Changes

5. WithOpen(true) is now a no-op with no migration path

Application code that used WithOpen(true) to initialize a component open will silently stop working. The deprecation comments say "has no effect" but provide no migration path. Consider documenting how to achieve pre-opened state via CSS class injection, or provide a WithOpenClass() Option.

6. Inline onclick handlers break strict Content-Security-Policy

The migration from lvt-click="toggle_X" to onclick="var r=this.closest(...)..." requires 'unsafe-inline' in script-src. The old lvt-* attributes were CSP-safe. This is a regression for applications with strict CSP. At minimum, note it in the CHANGELOG. Ideally, encode toggle behavior as a new framework directive (e.g., lvt-toggle:class="open") consistent with the existing lvt-el:* syntax.


Accessibility

7. aria-expanded resets to "false" on every server re-render

All trigger buttons now hardcode aria-expanded="false", relying on inline JS to flip it. Any server-triggered partial re-render will return aria-expanded="false" even if the panel is visually open. Screen readers will report the control as collapsed when it is expanded.

Affects: dropdown/default.tmpl, dropdown/searchable.tmpl, dropdown/multi.tmpl, menu/default.tmpl, menu/nav.tmpl, datepicker/single.tmpl, datepicker/range.tmpl, timepicker/default.tmpl, timepicker/duration.tmpl, popover/default.tmpl.


Design / Consistency

8. lvt-el:addClass:on:done="open" inconsistency across picker variants

datepicker/range.tmpl, timepicker/default.tmpl, and timepicker/duration.tmpl include this directive, but datepicker/single.tmpl does not. Is the omission intentional (close-on-select UX)? A comment explaining the difference would clarify intent.

9. Chevron indicator may be lost for styled kits

The trigger SVG previously used {{if $c.Open}}{{$c.Styles.TriggerIconOpen}}{{end}}. Now it is unconditional. If a kit uses rotate-180 for TriggerIconOpen, that visual indicator is lost unless kit CSS has a parent .open selector rule. Please verify kit CSS covers this.

10. Duplicate style blocks per component instance

Each component template emits its own style block via _shared.tmpl. Three dropdown instances produce three identical blocks. Browsers handle this correctly but it is unnecessary HTML weight — worth documenting as a known trade-off.


Code Quality

11. Stale docstring in timepicker.go

The "Required lvt-* attributes" comment still lists lvt-click and lvt-input, but the PR replaces lvt-click with onclick="..." and updates to lvt-on:input. Should be updated.

12. Searchable.Open interaction with lvt-el:addClass:on:focusin

The interaction between the server-controlled Open field and the lvt-el:addClass:on:focusin directive should be documented or tested more explicitly. If Open is false but a search has results, the panel will not show.


Test Coverage

13. E2E tests blocked on unpublished client

The test plan requires client v0.8.17+. The new lvt-on:* routing, lvt-el:* directives, and command/commandfor modal attributes are not validated by any currently-running tests. A follow-up issue should track the v0.8.17+ publish gate before this PR is fully validated.

14. NewInline() rendering is untested (see Bug #1)


Positive Observations

  • lvt-data-* to data-* is strictly correct and removes unnecessary namespace pollution.
  • The aria-controls fix (always rendered, not gated on Open) is correct per ARIA spec.
  • Removing Focus()/Blur()/Toggle()/Close() from 7 components appropriately reduces API surface area.
  • command/commandfor for modal open/close uses the emerging native HTML Invoker API.
  • Drawer animation via CSS custom property (--drawer-closed-transform) is a clean pattern.
  • searchable.tmpl correctly keeps Open server-controlled — a principled exception to the client-side rule.

Summary

Critical items before merging:

  1. Bug: Autocomplete panel does not close after selection (docs: Verify CI is working on main #2)
  2. Bug: NewInline() datepicker not visible by default (test: Verify CI workflow #1)
  3. Bug: ContextMenu.ShowAt() no longer opens the menu (Add Claude Code skills for lvt CLI #3)
  4. Breaking change: CSP regression from inline handlers needs CHANGELOG entry (feat(lvt): migrate templates to method dispatch pattern #6)
  5. Test gap: E2E blocked on unpublished client (fix: update E2E test paths and include EditingItem in JSON #13)

Items #7 (aria-expanded), #4 (tab-away), and #5 (WithOpen migration path) are worth tracking as follow-ups.

Reviewed by Claude Sonnet 4.6

…ck toggles

- Replace command="show-modal"/command="close" commandfor="X" with
  lvt-el:toggleAttr:on:click="hidden" data-lvt-target="#X" in all
  generator, kit, and test templates (23 occurrences)
- Replace onclick toggle handlers on dropdown, popover, datepicker,
  timepicker, and menu components with lvt-el:toggleClass:on:click="open"
  data-lvt-target="closest:[data-X]" (10 component templates)
- Update e2e test selectors from [commandfor="..."] to [data-lvt-target="..."]
- Update golden files and unit test assertions
- Remaining onclick: search clear (multi-action), confirm delete (browser
  dialog), drawer (conditional data-persistent check)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review — Confirmed Issues Summary

The prior review comments on this PR have been thorough. After reviewing the diff directly, here is a confirmed, prioritized summary of items that should be resolved before merging.


Must Fix (Bugs / Regressions)

1. ContextMenu.ShowAt() silently no longer opens the menu

Open = true was removed from ShowAt(). The comment says "The consuming page is responsible for adding the 'open' CSS class," but for a context menu triggered by a right-click action handler, the server is the consuming page. Any existing code calling ShowAt() from an action handler will update position but the menu will not appear. Suggest restoring Open bool on ContextMenu specifically.

2. Autocomplete panel stays open after suggestion selection

select_ID triggers a server re-render but nothing removes the open CSS class client-side. The fix is straightforward: add lvt-el:removeClass:on:done="open" to the root wrapper in both default.tmpl and multi.tmpl.

3. NewInline() datepicker renders hidden by default

NewInline() previously set Open = true. With Open removed and visibility controlled by the open CSS class, the inline calendar will be hidden on load. No code path sets open by default. The inline template needs open hardcoded into the root element's class.

4. Stale docstring in timepicker.go

Still lists lvt-click, lvt-input, lvt-click-away — these are old syntax. Should be updated to reflect the new onclick=..., lvt-on:input, and lvt-el:removeClass:on:click-away attributes actually used.


Should Address

5. aria-expanded resets to "false" on every server re-render

All toggled components hardcode aria-expanded="false", relying on inline JS to flip it. Any partial server re-render will incorrectly report the control as collapsed to screen readers. This is a meaningful accessibility regression. Worth tracking as a follow-up if not addressed here.

6. CSP regression from inline onclick handlers

The old lvt-* declarative attributes were CSP-safe. The new onclick="var r=this.closest(...)..." pattern requires 'unsafe-inline' in script-src. Needs a CHANGELOG entry at minimum. Ideally, encode toggle behavior as a framework directive (e.g., lvt-toggle:class="open") consistent with the existing lvt-el:* syntax.

7. WithOpen(true) is now a no-op with no migration path

Application code using WithOpen(true) to pre-open a component will silently stop working. The deprecation comment says "has no effect" but provides no alternative. A migration guide (e.g., use WithExtraRootClass("open")) would help adopters.


Minor

  • lvt-el:addClass:on:done="open" is present in range.tmpl, timepicker/default.tmpl, and timepicker/duration.tmpl but absent from single.tmpl. If intentional (close-on-select for single picker), a comment would clarify.
  • Duplicate <style> blocks per component instance are harmless but worth noting in docs as a known trade-off.
  • E2E tests are gated on unpublished client v0.8.17+. A follow-up issue should track this gate.

Positive Observations

  • lvt-data-*data-* is strictly correct.
  • aria-controls fix (always rendered, not gated on Open) is correct per ARIA spec.
  • command/commandfor for modal open/close is a forward-looking, standards-aligned choice.
  • The overall net diff (−1161/+664) shows real simplification.

Reviewed by Claude Sonnet 4.6

…play

With toggleAttr on hidden, the inline style.display stays "flex" but
the element is hidden via UA stylesheet. Tests now check hasAttribute('hidden')
instead of style.display for modal open/close verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: feat!: attribute reduction — client-side open/close + template migration

This is a well-motivated refactor that eliminates server round-trips for purely visual state. The breadth of coverage (76 template files, 12 e2e test files) is impressive. Here are my observations:


Bug: aria-expanded removed without a client-side replacement

Affected files: components/datepicker/templates/single.tmpl, components/datepicker/templates/range.tmpl, components/dropdown/templates/default.tmpl, components/dropdown/templates/searchable.tmpl, components/menu/templates/default.tmpl, components/timepicker/templates/default.tmpl, components/timepicker/templates/duration.tmpl

All trigger buttons previously emitted aria-expanded="{{.Open}}" (server-rendered). That line is deleted in every component, but no client-side substitute is added. The lvt-el:toggleClass:on:click directive only toggles a CSS class on the parent container — aria-expanded on the trigger button itself is never updated. Screen readers will never announce the expanded/collapsed state, which is a WCAG 4.1.2 violation for combobox/disclosure patterns.

Consider adding a second directive (e.g., lvt-el:toggleAttr:on:click="aria-expanded") or documenting this as a known gap. The components/menu/templates/nav.tmpl nav submenu button also loses its aria-expanded with no substitute.


Bug: NewInline datepicker behavioral regression

File: components/datepicker/datepicker.go

func NewInline(id string, opts ...Option) *DatePicker {
    dp := New(id, opts...)
    return dp  // previously set dp.Open = true
}

NewInline is now functionally identical to New. The comment says "the consuming template should add the 'open' class," but unless the lvt:datepicker:inline:v1 template itself was updated to include the open class unconditionally (not visible in this diff), inline datepickers will render as closed. This is a behavioral regression for existing NewInline users.


Security: CSP incompatibility from raw onclick handlers

Files: components/drawer/templates/default.tmpl, internal/generator/templates/components/search.tmpl, internal/generator/templates/components/toolbar.tmpl

Replacing lvt-click-away="close_X" with inline onclick="var d=this.closest('[data-drawer]');..." will break any app with Content-Security-Policy: script-src 'self' (no 'unsafe-inline'). The old lvt-* attributes were declarative directives processed by the framework's bundled script and therefore CSP-safe. The new onclick= attributes on overlay divs and close buttons are not.

The lvt-el:removeClass:on:click-away and lvt-el:toggleClass directives presumably still go through event delegation, so those are fine. The raw onclick= is the concern.


Inconsistency: lvt-focus-trap not renamed alongside other attributes

Files: components/dropdown/templates/default.tmpl, components/dropdown/templates/multi.tmpl

<div class="{{$c.Styles.Dropdown}}" data-dropdown-panel lvt-focus-trap role="listbox">

lvt-focus-trap is still present while other lvt-* attributes were renamed. Is this intentional (still supported in client v0.8.17+)? If so, a comment explaining why it wasn't renamed would help future maintainers. If it was missed, it should be updated.


Style: Non-canonical Deprecated: godoc marker

Files: all */options.go files with WithOpen

The current comment format:

// WithOpen is a no-op. Open/close is now handled client-side via CSS classes.
// Deprecated: This option has no effect.
func WithOpen(_ bool) Option {

Go tools (and IDEs) recognize the canonical form where Deprecated: appears as the first sentence of the doc comment:

// Deprecated: WithOpen is a no-op; open/close is now handled client-side via CSS classes.
func WithOpen(_ bool) Option {

This enables IDE strike-through and static analysis warnings for callers.


Test coverage gap: Searchable.Open = true rendering

The server-side Toggle(), Show(), Close() method tests were correctly deleted. However, the Searchable dropdown still has Open bool (intentionally, since server controls visibility based on MinChars/results). There are no unit tests verifying that class="... open" is emitted when Searchable.Open = true. Worth adding given this is the one component where server-side Open still matters.


Overall

The direction is architecturally sound and the implementation is thorough. The main items to resolve before merge:

  1. aria-expanded on client-toggled triggers — accessibility regression (medium severity)
  2. NewInline datepicker — renders closed without an inline template update (behavioral regression)
  3. Raw onclick= handlers — CSP incompatibility in drawer and generator templates (security/compatibility)
  4. lvt-focus-trap — confirm still valid in new client version or rename it

The deprecation marker and test coverage gap are minor and could be addressed in follow-up PRs if preferred.

Reviewed by Claude Sonnet 4.6

Each chromedp.ActionFunc is a separate closure — var hidden bool must
be declared at function level to be shared across all action closures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

This is a well-scoped, architecturally sound PR. Moving UI-only open/close state from the server to client-side CSS class toggling eliminates unnecessary round-trips and the attribute naming migration makes the framework more idiomatic. The breadth of coverage — component Go files, tests, templates, generator templates, kit templates, and e2e tests — is impressive for a single PR.


Potential Bug: Autocomplete focusin/focusout race condition

Severity: Medium

components/autocomplete/templates/default.tmpl and multi.tmpl use:

<div ... lvt-el:addClass:on:focusin="open" lvt-el:removeClass:on:focusout="open">

On the web platform, when a user clicks a suggestion, the sequence is: mousedownfocusout (input loses focus) → mouseupclick. If focusout fires on the container before the click fires on the <li>, the open class will be removed and the list will be hidden before the selection registers.

This is a classic focus-based visibility issue. Common solutions:

  • Check relatedTarget in the focusout handler (is focus still inside the container?)
  • Use mousedown + preventDefault() on list items to retain input focus

Worth verifying this doesn't break item selection, especially across browsers.


Accessibility: aria-expanded may diverge from visual state

Severity: Medium

In the autocomplete templates, aria-expanded is set from server state:

aria-expanded="{{$c.Open}}"

But open/close is now driven by the open CSS class on the container (client-side). After the initial render, the aria-expanded attribute won't reflect the actual visual state unless something updates it in sync with the CSS class change.

Screen readers rely on aria-expanded to announce the listbox state. Consider hooking the client-side lvt-el class manipulation to also update aria-expanded on the input, or setting aria-expanded based on the CSS class via JavaScript.


Duplicated inline JavaScript in drawer template

Severity: Minor / Maintenance

components/drawer/templates/default.tmpl duplicates the persistent-check logic in two places:

<!-- overlay -->
onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

<!-- close button -->
onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

If the logic changes (e.g., a new condition or a different attribute name), both must be updated in sync. Consider naming this a known limitation, or factoring it into the client library as a lvt-el action once that's feasible.


WithOpen deprecation is inconsistent across components

Severity: Minor

components/popover/options.go keeps WithOpen as an explicit no-op with a deprecation comment — good for backward compatibility:

// Deprecated: This option has no effect.
func WithOpen(_ bool) Option {
    return func(_ *Popover) {}
}

But dropdown, drawer, datepicker, and other components simply removed their Open fields and any WithOpen-equivalent options entirely. If consumers of those APIs used WithOpen or set .Open = true, they'll get a compile error with no guidance.

Recommend either applying the no-op deprecation pattern consistently, or at minimum documenting the migration path in the PR description / CHANGELOG.


NewInline DatePicker: behavioral contract not enforced

Severity: Minor

// NewInline creates an inline calendar.
// The consuming template should add the "open" class to the root element
// to make the calendar panel always visible.
func NewInline(id string, opts ...Option) *DatePicker {
    dp := New(id, opts...)
    return dp
}

NewInline now has no behavioral difference from New — the "always open" behavior is entirely the caller's responsibility. This is a silent regression if anyone upgrades and doesn't read the comment. Consider whether NewInline should set a field (e.g., AlwaysOpen bool) that the template then reads to apply the open class automatically.


Design note: Searchable dropdown is intentionally asymmetric

The comment in dropdown.go explains why Searchable.Open remains server-controlled:

// Open is server-controlled for searchable dropdowns because visibility
// depends on whether the query meets MinChars and results exist.

This is a reasonable exception and well-documented. Worth calling out explicitly in the component's package-level doc comment so consumers aren't surprised by the inconsistency with Dropdown.


Process note: E2E tests pending client v0.8.17+

The unchecked item in the test plan is noted. Merging with untested browser behavior is a risk for a breaking change PR. Suggest either:

  • Blocking merge until the client is published and e2e tests pass, or
  • Adding a follow-up issue to track the e2e validation before any v0.8.x tag

Summary

The core change is correct and the execution is thorough. The main items to address before merge are the autocomplete focus/click race condition and the aria-expanded drift — both affect real users. The rest are lower-priority polish and documentation concerns.

… dialog

Simplify 3-step server flow (request_delete → show modal → confirm_delete
→ delete) to 1-step: name="delete" onclick="return confirm('...')" with
data-id. The browser native confirm dialog handles "are you sure?" and
the delete routes directly to the server.

Changes:
- Merge RequestDelete + ConfirmDelete + CancelDelete into single Delete
  handler that takes ID directly from BindAndValidate
- Remove DeleteConfirm modal state and PendingDeleteID from state struct
- Remove confirm modal initialization and template rendering
- Update delete buttons: request_delete → delete with onclick confirm
- Update layout JS routing: confirm_delete → delete
- Update golden files and test assertions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review: feat! — attribute reduction, client-side open/close + template migration

This is a large, well-scoped refactor with a clear architectural rationale. The changes are consistently applied across the codebase and the test suite follows the code changes faithfully. Overall this is a solid PR, with a few items worth discussing before merge.


Architecture / Design

Positive: The core premise — eliminate server round-trips for purely UI-open/close state — is sound. Moving dropdown/menu/popover/drawer/datepicker/timepicker/autocomplete panel visibility to CSS class toggling reduces WebSocket traffic for operations that carry no server-side data.

Concern: Persistent drawer no longer enforced server-side.
Previously Close() respected Persistent: true and ForceClose() was required to override it. In the new template the overlay's onclick performs the same guard inline:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This logic now lives in two separate inline handlers (overlay + close button). Any third-party code that drives the drawer via a lvt-el:removeClass directive won't consult data-persistent. The old Go-level invariant is now a client-side convention. Consider documenting this in the component's Go doc or adding a note in the template.

Concern: ContextMenu.ShowAt no longer opens the menu.
Previously ShowAt set coordinates and set Open = true, making a single call sufficient to display the context menu. Now:

// ShowAt sets the context menu position.
// The consuming page is responsible for adding the "open" CSS class to show it.
func (cm *ContextMenu) ShowAt(x, y int) {
    cm.X = x
    cm.Y = y
}

Any user handler that called only ShowAt will now render the panel hidden. This is a silent behavioral regression for existing users. The doc comment is helpful, but consider whether this warrants a note in the PR description or a CHANGELOG entry. The test TestShowAt now only validates coordinates, so there is no compile-time signal for callers.


Template Consistency

Minor: lvt:autocomplete:panel-css emits a <style> tag on every render.
The _shared.tmpl pattern used for autocomplete, datepicker, timepicker, and dropdown injects a <style> block via {{template "lvt:X:panel-css"}} inside the component template. If two instances of the same component appear on a page, duplicate identical <style> blocks are emitted. This is harmless for correctness but noisy. The drawer instead inlines its CSS once per render. A comment acknowledging this trade-off (or a TODO) would be useful.

Minor: aria-expanded removed without replacement on several trigger buttons.
Buttons like the dropdown trigger, datepicker/timepicker trigger, and menu trigger previously emitted aria-expanded="{{$c.Open}}". Those lines are removed in this PR. Because open/close is now CSS-class-driven and the server no longer knows the open state, the attribute can't be kept in sync via template rendering. This is an inherent trade-off of the client-side approach. Noting it explicitly in the PR (or adding a client-side JS toggle of aria-expanded alongside the class toggle) would improve accessibility for screen reader users.


Deprecated Options

The pattern used for deprecated WithOpen options is clean and compiles without warnings:

// Deprecated: This option has no effect.
func WithOpen(_ bool) Option {
    return func(_ *Drawer) {}
}

Consider whether a //nolint:staticcheck comment is needed or whether you want to use a log.Println warning in dev mode. The current silent no-op is the right default for backward compatibility, but users upgrading won't know WithOpen stopped working unless they read the godoc.


Inline JavaScript Patterns

The drawer template introduces inline JS for close-on-overlay and close-button clicks:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This is duplicated verbatim in two places within the same template (overlay div and close button). If the logic ever needs to change (e.g., dispatching a custom event on close), it must be updated in two places. Extracting to a named helper or a small inline <script> function at the top of the drawer template would make this easier to maintain.


Generator template: delete action changed silently

In internal/generator/templates/components/edit.tmpl, the delete button changes from:

lvt-click="request_delete" lvt-data-id="{{.EditingID}}"

to:

name="delete" data-id="{{.EditingID}}" onclick="return confirm(...)"

And layout.tmpl updates the intercept from confirm_delete to delete. This is a two-part action name change (request_deletedelete, confirm_delete → gone) with no intermediate confirmation step — the confirm is now inline window.confirm. Existing generated apps that added custom handler logic keyed on request_delete or confirm_delete action names will break silently. This is the most impactful behavioral change for existing users and deserves prominent mention in the PR description or migration notes.


Test Coverage

  • Removed tests for Toggle, Show, Close, ForceClose, Focus, Blur are appropriate since those methods are gone.
  • The TestWithOpen tombstone tests (_ = d // should not panic) are minimal but correct.
  • E2E tests are updated consistently. The one remaining gap is that no test verifies that two instances of the same panel-bearing component on a single page don't interfere with each other's CSS visibility (both share the same [data-X]:not(.open) CSS selector, which should work fine, but worth a note).

Minor nits

  1. e2e/modal_test.gohidden != true and hidden != false should be hidden and !hidden for idiomatic Go.
  2. components/drawer/templates/default.tmpl — The inline <style> block at the top of the template is not wrapped in a named define (unlike the autocomplete/dropdown _shared.tmpl approach). Both patterns exist in the same PR; picking one consistently would be cleaner.
  3. datepicker/templates/range.tmpl uses lvt-el:addClass:on:done="open" — is done a standard lvt event name? If so, it would be helpful to add a comment explaining what triggers it (e.g., after a server action completes).

Summary

Category Status
Core refactor direction ✅ Sound
Consistency across 76+ files ✅ Good
Breaking change: ContextMenu.ShowAt behavior ⚠️ Silent regression for existing callers
Breaking change: generator delete action names ⚠️ Needs migration note
aria-expanded accessibility regression ⚠️ Trade-off, worth documenting
Persistent drawer logic duplication ℹ️ Minor
Duplicate <style> tags per component instance ℹ️ Harmless but noisy
Test coverage ✅ Adequate
E2E tests not yet runnable (pending client v0.8.17+) ℹ️ Noted in test plan

The two breaking behavior changes (ShowAt and the delete action name change in generated apps) are the items I'd most want addressed or at minimum clearly documented before merging.

🤖 Generated with Claude Code

Replace multi-step request_delete → confirm modal → confirm_delete flow
with single delete button click. In headless Chrome, window.confirm()
auto-returns true, so the browser confirm dialog is transparent to tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: feat!: attribute reduction — client-side open/close + template migration

This is a substantial, well-executed migration with a clear architectural rationale. The net reduction of ~1,200 lines demonstrates meaningful simplification, and the direction of moving pure UI toggle state to the client is correct. The attribute syntax unification (lvt-on:{event}, lvt-el:, lvt-mod:, lvt-form:) is internally consistent across all 76+ templates.

Below are the concerns worth addressing before merge.


Potential Bugs

1. lvt-el:addClass:on:done on multi-dropdown will always reopen after server actions

In the multi-dropdown template, lvt-el:addClass:on:done="open" is applied to re-add the open class after every server round-trip (e.g., toggle_item). This means after a user selects an item, the panel will always reopen — even if the user intended to close it. This needs verification against product intent. If unintended, the trigger should only re-add open when the panel was open before the action.

2. NewInline datepicker breaks silently for existing callers

Previously, NewInline set Open = true so the calendar was always visible. The new contract requires the consuming template to add the open CSS class manually. This is a silent behavioral break — existing inline datepicker usages will render as closed with no compile-time error. A migration note or helper is needed, and the old TestNewInline test was deleted without a replacement asserting visible rendering.

3. ContextMenu.ShowAt semantic gap

ShowAt now only sets X/Y coordinates but does not open the menu. Server-side code that calls ShowAt in response to an event still needs the client to separately add the open class. This split API is fragile and the mechanism for server-triggered open is unclear.


Accessibility Regression

aria-expanded removed from trigger buttons

Multiple components (dropdown, datepicker, timepicker, menu, popover) removed aria-expanded="{{.Open}}" from their trigger buttons without replacement. Screen readers use aria-expanded to communicate open/closed state — its absence is an accessibility regression. The lvt-el: directives that manage the CSS class should also manage aria-expanded (e.g., lvt-el:attr:aria-expanded). This should be addressed before merge.


Consistency Issues

4. Drawer uses inline onclick JavaScript instead of lvt-el: directives

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This appears twice in the drawer template. All other components use lvt-el: directives for class management — the drawer should too. Inline JavaScript also requires unsafe-inline CSP, creating friction for applications with strict content security policies.

5. parent_injector_test.go retains stale lvt-click attribute

The test fixture still uses lvt-click="delete" (old syntax) rather than name="delete". This is a minor quality issue but signals an incomplete migration in test fixtures.


Test Coverage Gaps

  • E2E tests are not passing due to unpublished client v0.8.17+. This is the primary integration test surface — it should be green before merge (or there should be a clear plan to unblock it).
  • No test for the aria-expanded removal (accessibility regression).
  • No test for lvt-el:addClass:on:done "reopen after server action" behavior on multi-dropdown.
  • TestNewInline was deleted without replacement; the behavioral contract change for inline calendar rendering is untested.

Minor Notes

  • Duplicate <style> tags: Each component injects its own <style> block. Multiple instances of the same component on a page will emit duplicate styles. Browsers handle this gracefully, but it adds HTML weight. Worth addressing in a follow-up by injecting component styles once per page (e.g., in a layout head template).
  • confirm() in delete flow: Browser confirm() dialogs auto-accept in headless Chrome, so e2e tests no longer validate the real user flow. The dialog is also not styleable and can be blocked by browser policies. If UX parity matters, a modal confirmation approach is preferable long-term.
  • WithOpen() no-ops: Well-handled — preserving compile-time compatibility while documenting deprecation is the right call.

Summary

Area Status
Architecture / direction ✅ Sound
Attribute syntax migration ✅ Consistent
Server round-trip elimination ✅ Genuine improvement
aria-expanded / accessibility ❌ Regression — should fix before merge
NewInline datepicker contract ⚠️ Silent break — needs migration path
Multi-dropdown reopen behavior ⚠️ Needs verification
Drawer inline JS ⚠️ Inconsistent with lvt-el: pattern
E2E tests passing ❌ Blocked on client v0.8.17+

The execution is disciplined and the scope is well-managed. The aria-expanded regression and the E2E test gap are the two items I'd want resolved before this lands on main.

…e test

- Override window.confirm = () => true before clicking delete buttons
  in headless Chrome (confirm() blocks JS execution in some drivers)
- Remove Escape key test from standalone modal test (requires active
  WebSocket for client event delegation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

This is a well-scoped architectural improvement — moving UI-only visibility state to the client eliminates unnecessary server round-trips and simplifies the server-side component model. The attribute rename is systematic and consistent across 124 files. A few issues worth addressing before merging:


Bugs

1. focusin/focusout race condition in autocomplete

In autocomplete/templates/default.tmpl and multi.tmpl:

lvt-el:addClass:on:focusin="open"
lvt-el:removeClass:on:focusout="open"

When a user clicks a suggestion <li>, the browser fires focusout on the container before the click event fires on the item. This removes the open class and hides the panel via CSS (display:none), cancelling the click. The old lvt-click-away approach avoided this entirely. Consider using mousedown + preventDefault on the suggestion panel, or switching to click-away for close (which is already on the element) and removing the focusout handler.

2. aria-expanded is stuck at initial server value for autocomplete

autocomplete/templates/default.tmpl:

aria-expanded="{{$c.Open}}"

$c.Open is no longer set to true by any server method (both Focus() and Blur() were removed, and query_{{$c.ID}} only calls Filter()). So aria-expanded will always render false after initial load, breaking screen reader announcements. Either update the query_ handler to set Open based on results, or manage aria-expanded client-side like datepicker does (where it was removed from the template entirely).

3. MinChars behavior regression in autocomplete

The removed Focus() method enforced MinChars:

if len(ac.Query) >= ac.MinChars && len(ac.FilteredSuggestions) > 0 {
    ac.Open = true
}

The replacement lvt-el:addClass:on:focusin="open" always shows the panel on focus, regardless of MinChars. Users with MinChars > 0 will now see an empty dropdown flash open on every focus. This is a behavioral regression.

4. HighlightedIndex stale after client-side close

Blur() reset ac.HighlightedIndex = -1. Now when a user dismisses the panel via click-away, the server-side HighlightedIndex retains its last value. On the next open + keydown cycle the highlight will be visually correct (the server updates it), but the initial render on reopen could show a stale highlight for one frame. Lower priority, but worth noting.


API / UX

5. WithOpen silently becomes a no-op

For popover, drawer, datepicker, etc.:

// Deprecated: This option has no effect.
func WithOpen(_ bool) Option {
    return func(_ *Popover) {}
}

Any existing code calling WithOpen(true) will silently do nothing — no panic, no warning, no log. This will manifest as an invisible regression: the component won't open on load as the user expects. Consider logging a deprecation warning at runtime, or at minimum making the godoc more emphatic ("Breaking: This option no longer works. Remove all calls.").


Maintainability

6. Repeated inline JS in drawer templates

The same inline handler appears multiple times:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This is repeated for the overlay click, the close button, and potentially other triggers. Any logic change requires hunting down every copy. Consider centralizing this in the client library as a named action (e.g., lvt-el:removeClass:on:click="open" data-lvt-guard="not:data-persistent:closest:[data-drawer]") or a small inline helper function in a <script> block shared via template.


Testing

7. E2E tests are not passing

The PR description explicitly notes [ ] E2E browser tests (require published client v0.8.17+). Merging with known failing E2E tests is risky — it means the full integration has not been validated. Is there a timeline for v0.8.17 being available? Could the E2E tests be skipped/tagged until the client is published, rather than leaving them unchecked?


Positives

  • The direction is right: CSS-based toggle is faster and simpler than server round-trips for pure UI state.
  • The lvt-clickname (Tier 1) vs lvt-on:click (Tier 2) distinction is clear and principled.
  • Always-present aria-controls in autocomplete is an accessibility improvement over the old conditional version.
  • The rename is consistent with zero straggler lvt-* attributes remaining — the parity tests enforcing this are great.
  • Golden file regeneration and the 24-package component test pass give good confidence in the Go-side changes.

…tests

- Delete buttons with type="button" inside forms use lvt-on:click="delete"
  instead of name="delete" (orphan button detection requires btn.form=null)
- Remove rapid cycle and escape key tests from standalone modal test
  (WebSocket disconnects in standalone page; covered by full e2e tests)
- Update golden files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

This is a well-scoped, coherent refactor. Moving open/close state client-side and standardizing on name/lvt-on:/lvt-mod:/lvt-form: syntax is a sound direction. The breadth and consistency of the migration across 76+ templates is impressive. A few issues worth addressing:


Bug: aria-expanded removed entirely from trigger buttons

Dropdown, datepicker (single + range), timepicker, popover, and menu triggers all previously had aria-expanded="{{.Open}}". This PR removes the attribute entirely rather than replacing it. Screen readers use aria-expanded to announce the open/close state of popups — its absence means users with assistive technologies get no feedback when clicking these triggers.

The autocomplete input correctly retains aria-expanded="{{$c.Open}}" since it's still server-tracked. For the client-side components, the attribute should be toggled by the same JS that toggles the open class (or via a lvt-el:toggleAttr directive if that's supported).


Bug: NewInline datepicker is now always closed

Previously NewInline set dp.Open = true. Now it just returns the component with the comment "consuming template should add the 'open' class to the root element." But nothing in the returned struct or any template adds that class — so inline calendars will render hidden by default. If the intent is to keep NewInline as a convenience constructor, it should at least set a field (e.g., AlwaysOpen bool) that the template uses to add the open class unconditionally.


lvt-el:addClass:on:done="open" will reopen panels after any server response

datepicker/range.tmpl and timepicker/default.tmpl both have lvt-el:addClass:on:done="open" on the root element. This fires after any server round-trip that targets this component — not just a date/time selection. If a parent component re-renders the page (e.g., a form submission), or if there are any validation responses, the picker will pop open unexpectedly. Compare: dropdown and menu do not have this attribute.

Either remove it and let the CSS class persist across re-renders, or scope it to the specific actions that should reopen the panel.


CSP regression from inline event handlers

Drawer close buttons now use:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

And generated delete buttons use onclick="return confirm('...')". These are blocked by Content-Security-Policy: script-src 'self' (no unsafe-inline). The old lvt-click attributes were processed by the framework script and were CSP-safe. Consider encoding these as framework directives (e.g., lvt-el:removeClass:on:click="open" for the drawer close) rather than raw inline JS for consistency and security.


WithOpen no-op silently ignores user intent

timepicker/options.go and popover/options.go now have:

// Deprecated: This option has no effect.
func WithOpen(_ bool) Option {
    return func(_ *TimePicker) {}
}

Keeping it for API compatibility is fine, but WithOpen(true) was previously the way to open a component programmatically. Any existing app code using this will silently do nothing after upgrade. Consider whether the component should at least initialize with the open CSS class when WithOpen(true) is passed — i.e., render class="... open" initially.


Duplicate <style> injection

Each component template emits its own <style> block inline. Three dropdowns on a page → three identical [data-dropdown]:not(.open) [data-dropdown-panel]{display:none} rules. Browsers handle duplicate rules fine, but it's worth noting as a known trade-off vs. moving these rules into the client library stylesheet.


Minor

  • The aria-controls fix in autocomplete (always rendered, not gated on {{if $c.Open}}) is correct per ARIA spec.
  • The lvt-el:toggleAttr:on:click="hidden" for modal close is a different pattern from the CSS .open class approach used by other components — might be intentional given <dialog> semantics but worth a note.
  • delete action replacing confirm_delete in the layout client-side handler is more semantic, but delete is a generic name — worth ensuring it doesn't conflict in multi-resource pages.
  • E2E tests gated on client v0.8.17+ (noted in PR) — worth a tracking issue for that dependency.

In-form delete buttons now use lvt-on:click="delete" instead of
name="delete". E2e selectors updated to match both patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: feat! attribute reduction — client-side open/close + template migration

This is a large, well-scoped refactor. The direction is sound — moving UI-only open/close state to client-side CSS eliminates unnecessary server round-trips, and the attribute rename aligns the API around standard HTML conventions. The diff is impressively balanced (766 additions vs 2067 deletions). A few issues are worth addressing before merge.


Critical

1. Inline onclick in drawer breaks Content Security Policy

components/drawer/templates/default.tmpl introduces:

onclick="var d=this.closest('[data-drawer]');if(!d.hasAttribute('data-persistent'))d.classList.remove('open')"

This inline JavaScript will fail under any CSP that doesn't include unsafe-inline. The old lvt-click attributes were declarative and didn't have this problem. Consider moving this logic to a lvt-el: directive or a named function in the framework's JS runtime instead.

2. aria-expanded removed with no replacement

Trigger buttons in dropdown, menu, datepicker, timepicker, and popover previously had aria-expanded="{{.Open}}". These are removed, and no client-side equivalent (e.g. lvt-el:toggleAttr:on:click="aria-expanded") replaces them. Screen readers will no longer announce open/closed state for these controls. This is an accessibility regression that affects all users of assistive technology.


Behavioral Breaking Changes (need docs or migration notes)

3. NewInline() no longer renders the calendar open

datepicker.NewInline() previously set Open=true to ensure the inline variant was always visible. Now it's identical to New(). The docstring says consuming templates must add the "open" class manually — but there's no example of how to do this and no inline template variant. Users who relied on NewInline() will silently get a closed calendar. At minimum, the docstring should show the HTML attribute to add.

4. ContextMenu.ShowAt() broken contract

ShowAt(x, y) previously showed the menu. Now it only positions it. The caller must separately add the "open" CSS class via client-side code. This is a silent breaking change — calling ShowAt() from a Go action handler will position but not reveal the menu. The change is noted in the docstring, but there's no guidance on how to trigger the "open" class from a server action (if that's even possible in the new model).

5. WithOpen(true) on dropdown/menu/popover is now a permanent no-op

There's no longer any way to render these components in their open state on initial server render. This matters for SSR scenarios like "open this dropdown by default" or "show the menu pre-expanded on page load." The deprecated WithOpen options silently do nothing instead of erroring. If initial-open SSR is intentionally unsupported now, that's worth calling out explicitly in the PR.


Mixed Model / Potential Race Condition

6. Autocomplete has a conflicting dual open/close mechanism

The autocomplete component keeps a server-side Open bool field that SetQuery() and Select() mutate. The template renders {{if $c.Open}}open{{end}} on the root div's class. But the template also uses lvt-el:addClass:on:focusin="open" and lvt-el:removeClass:on:focusout="open".

These two mechanisms can race: focusout fires before the server responds to a Select() action, so the panel may flash closed and then get re-opened by the server response (or vice versa). Consider whether the server Open field should be removed from autocomplete entirely (client-only, like the others) or whether the client-side directives should be removed (server-only).


Minor / Nits

7. PR description inaccurate about modal migration

The description says lvt-modal-open/closecommand/commandfor (native dialog). The actual diff uses data-lvt-target="#id" and lvt-el:toggleAttr:on:click="hidden" — the native Invoker Commands API (commandfor) does not appear in the diff. Worth updating the description to match the implementation.

8. Stale old-syntax fixture in parent_injector_test.go

The test fixture still uses lvt-click="delete" and lvt-data-id= (old syntax). If this is intentional (testing the injector against legacy templates), add a comment saying so. Otherwise it should be migrated.


What's Good

  • The CSS :not(.open) pattern using data-* scoped selectors is clean and avoids Tailwind class coupling.
  • The --drawer-closed-transform CSS custom property approach for the drawer is elegant.
  • Using onclick="return confirm(...)" for delete confirmation removes the request/confirm two-step modal overhead significantly.
  • Deprecation wrapper pattern for WithOpen() (no-op with docstring) is the right backwards-compatible approach.
  • lvt-el:addClass:on:done="open" on multi-select, range datepicker, and timepicker correctly preserves panel visibility after server round-trips.
  • E2E test simplification (removing the confirm modal step) is a net positive.

Summary: The CSP regression (#1) and accessibility regression (#2) should be resolved before merge. Items #3#5 need at minimum clear migration docs. The autocomplete race condition (#6) is worth investigating. Everything else is minor.

Go string \\: → JS string \: → CSS escaped colon in attribute name.
Single backslash was producing unescaped colon interpreted as pseudo-class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review — PR #292: Attribute Reduction (Client-Side Open/Close + Template Migration)

This is a substantial, well-scoped refactor with +766/-2067. The overall direction is sound — removing server round-trips for purely visual state is a clear win. The migration is thorough and consistent. Below are observations, concerns, and questions worth addressing.


Architecture / Design

Positive: The open/close client-side shift is architecturally correct.
Components like dropdown, menu, popover, drawer, datepicker, timepicker, and autocomplete had no server-side reason to track Open state — the server didn't compute anything differently based on it. Eliminating those fields, methods, and round-trips simplifies the model substantially.

Concern: Searchable dropdown retains Open while other dropdowns don't.
In components/dropdown/dropdown.go, the Searchable struct re-introduces Open bool with a comment that visibility depends on MinChars and query results. This asymmetry is worth documenting more prominently. The other dropdown variants fully delegate open/close to the client. If a consumer does NewSearchable() and reads Open, they'll behave differently than New(). Consider whether this asymmetry should be noted in the package-level doc comment, or whether the searchable open logic could also be CSS-only (e.g., the server adds/removes the open class via an lvt-el:addClass on the input when results exist).

Concern: ContextMenu.ShowAt() no longer opens the menu.
Previously, ShowAt(x, y) set position AND set Open = true. Now it only sets position — the consuming page is responsible for adding the open class. The comment says so, but the function signature has not changed. Any existing user code calling ShowAt() expecting the menu to appear will be silently broken. This is a breaking API change that deserves a clear migration note in the function doc and ideally in the PR description's breaking changes section.

Concern: NewInline() for the datepicker no longer opens the calendar.
The comment was updated to say "The consuming template should add the 'open' class," but NewInline was specifically designed to create an always-visible calendar. Without the open class being applied automatically or via template, the "inline" variant will render closed by default, which contradicts its purpose. The datepicker inline template does not appear to add open automatically (the CSS rule is [data-datepicker]:not(.open) [data-datepicker-panel]{display:none}). This looks like a regression — an inline calendar that is initially hidden and requires a user click to open is just a regular datepicker.


Template / HTML Issues

Issue: Modal lvt-el:toggleAttr:on:click="hidden" is the wrong semantics for showing.
In the resource template files, the "Add" button now uses:

lvt-el:toggleAttr:on:click="hidden" data-lvt-target="#add-modal"

toggleAttr on hidden will alternate between adding and removing the hidden attribute on each click. This means the first click shows the modal (removes hidden), but a second click on the button hides it again. Cancel and Close buttons use the same toggleAttr pattern — so they too could show the modal if it was already hidden. The intent is show-once/close-once, not toggle. A lvt-el:removeAttr:on:click="hidden" for open and lvt-el:addAttr:on:click="hidden" for close would be more precise and safer.

Accessibility: aria-expanded removed from trigger buttons.
Multiple components (dropdown, datepicker, timepicker, popover, menu) previously set aria-expanded="{{.Open}}" on the trigger button. This attribute is now gone entirely. The correct accessibility pattern is to keep aria-expanded in sync with the open state. Without it, screen reader users cannot determine whether a popup is currently open. Since open/close is now CSS-only, aria-expanded must be toggled by the client JS alongside the open class. Confirm that lvt-el:toggleClass in the client library also updates aria-expanded on the trigger, or add a separate lvt-el:toggleAttr for aria-expanded. If this is not handled, this PR introduces an accessibility regression across all 7 affected components.


Security

Note: onclick="return confirm(...)" is fragile and bypassable.
The delete flow was simplified from a server-rendered confirm modal to onclick="return confirm('...')". The E2E tests override window.confirm = () => true to work in headless mode. Issues to consider:

  1. Any JavaScript on the page can override window.confirm, enabling bypasses if a script is injected.
  2. A user (or another script) calling deleteButton.click() programmatically bypasses the confirm — as the tests themselves demonstrate.

This is an acceptable trade-off in many contexts, but the behavior change should be documented: the previous two-step (request → confirm modal → delete) flow rendered the confirmation from trusted server HTML. The new single-step flow with a browser dialog is weaker.


Persistent Drawer Regression

Concern: lvt-el:removeClass:on:click-away="open" ignores data-persistent.
The old Close() method respected d.Persistent — it would not close a persistent drawer. Now default.tmpl has:

lvt-el:removeClass:on:click-away="open"

This will remove open regardless of data-persistent. If the client's click-away handler does not check for data-persistent, persistent drawers will now close on outside click — a behavioral regression.


Inline <style> Injection

Minor: Duplicate <style> blocks per page.
Each component variant injects its own <style> block. If a page contains multiple instances of the same component type, the same <style> block is emitted multiple times. Go's html/template does not deduplicate template block inclusions. Also, the drawer injects its <style> block directly in default.tmpl rather than using the named template pattern used by all other components — this inconsistency should be aligned.


Deprecated WithOpen Options

Positive: The no-op stubs with Deprecated doc comments are the right approach. Keeping WithOpen as a no-op preserves source compatibility for existing consumers.


Test Coverage

Gap: NewInline() datepicker initial state has no test.
The test TestNewInline previously verified dp.Open == true. After removal, the test only checks dp.ID(). No test verifies that an inline datepicker renders with the panel visible. Since NewInline no longer sets Open = true, and the inline template does not appear to add open automatically, this is potentially a functional regression with no test catching it.

E2E tests are incomplete.
The PR description notes that E2E browser tests require client v0.8.17+ and are marked incomplete. This means the primary behavioral correctness signal for all 7 refactored components is gated on an unpublished client version. Merging before those tests pass accepts that risk explicitly.


Minor / Style

  • The deletion of the WebSocket interceptor and screenshot debugging code from tutorial_test.go is a cleanup win.
  • The removal of emoji from test log messages is noted in some files but not all — tutorial_test.go still contains in t.Fatal calls, inconsistent with cleanup done elsewhere.
  • e2e/tutorial_test.go removes the check for "no delete button in table rows (modal mode)" — verify this is intentional and the tutorial UX intent is preserved with the new confirm() pattern.

Summary

The refactor is well-executed and consistent across the template files. Main concerns to resolve before merge:

  1. Accessibility regressionaria-expanded removal from trigger buttons needs to be confirmed as handled by the client library.
  2. Persistent drawer — verify lvt-el:removeClass:on:click-away respects data-persistent.
  3. NewInline() datepicker — the panel will be hidden by default; either auto-apply the open class in the template or update the docs to make this clear.
  4. ContextMenu.ShowAt() breaking change — callers expecting it to also open the menu need explicit migration guidance.
  5. Modal toggleAttr semantics — reconsider whether toggle is correct vs. explicit show/hide for open/close buttons.
  6. E2E tests incomplete — behavioral correctness of client-side open/close is unverified until client v0.8.17+ is available.

type="button" with name="X" inside a <form> is not caught by orphan
button detection (btn.form !== null). Use lvt-on:click="X" instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: feat!: attribute reduction — client-side open/close + template migration

This is a well-motivated refactor — moving open/close state to the client reduces unnecessary server round-trips and simplifies component structs significantly. The net deletion of ~1,300 lines speaks for itself. Here's my detailed feedback:


Bugs / Correctness

1. hidden attribute in datepicker single template

In components/datepicker/templates/datepicker_single.tmpl, the panel div has hidden hardcoded:

<div data-datepicker-panel hidden>

The CSS rule in _shared.tmpl hides the panel when the parent lacks .open, but nothing removes the hidden attribute when .open is added. hidden takes precedence over display CSS, so the panel will never be visible. The range template does not have this bug — it should be removed from the single template too.

2. lvt-el:addClass:on:done="open" reopens on any server response

In components/datepicker/ and components/timepicker/, the trigger button has:

lvt-el:addClass:on:done="open"

This means any server round-trip (not just a selection) will reopen the panel. Dropdown and menu do not use this pattern. If a user makes a selection and then a sibling form submits, the picker will unexpectedly pop open again. Consider scoping this to a specific action name, or removing it if the design intent is just to keep the picker open during date navigation.


Accessibility Regression

3. aria-expanded always resets to "false" on server re-render

All migrated trigger buttons have aria-expanded="false" hardcoded in templates. Since open/close state is now purely client-side (CSS class), every server re-render resets aria-expanded to false even if the panel is visually open. Screen readers will announce the wrong state.

Consider a CSS-driven approach using [data-*]:has(.open) button { /* aria-expanded via JS */ } or ensure the lvt-el:toggleClass directive also updates aria-expanded. At minimum, this should be called out in the PR description as a known limitation.


Security / CSP

4. Inline onclick JS in drawer breaks strict CSP

components/drawer/templates/drawer.tmpl introduces raw inline JS:

onclick="var d=this.closest('[data-drawer]'); if(!d.dataset.persistent) d.classList.remove('open');"

This is incompatible with Content-Security-Policy: script-src 'self' (no 'unsafe-inline'). The rest of the migration uses declarative lvt-el:* directives specifically to avoid this. A new directive like lvt-el:removeClass:on:click:if:not:data-persistent="open" would be more consistent. If that's not feasible yet, this should be documented as a CSP caveat.


Code Quality

5. Stale docstring in timepicker.go

The package-level docstring still references lvt-click and lvt-input (old syntax), but the template now uses onclick=... and lvt-on:input. Docs should be updated to match.

6. Duplicate <style> injection per component instance

Each component instance renders its own <style> block via _shared.tmpl. Three dropdowns on a page = three identical CSS rules in the DOM. This isn't broken, but it would be worth a brief comment in _shared.tmpl acknowledging the trade-off (simplicity vs. deduplication) so future contributors understand the intent.


Minor / Nits

  • WithOpen() is silently converted to a no-op in datepicker/timepicker. Consider logging a deprecation warning or adding a //Deprecated: godoc comment so callers know it has no effect.
  • The Searchable dropdown is the only component retaining Open bool for server-side visibility control. This exception is reasonable but worth calling out in a comment near the field, since the rest of the package has moved away from it.
  • E2e tests are gated on client v0.8.17+ — the unchecked checkbox in the PR description suggests this gate isn't merged yet. Make sure CI reflects this dependency before merge.

Summary

Category Finding Severity
Bug hidden attr on datepicker single panel prevents display High
Bug lvt-el:addClass:on:done="open" reopens on any server response Medium
Accessibility aria-expanded always resets to false on re-render Medium
Security Inline onclick in drawer breaks strict CSP Medium
Docs Stale docstring in timepicker.go Low
Performance Duplicate <style> per instance Low

The overall direction is correct and the migration is thorough. Fixing the hidden attribute bug and the CSP issue in drawer are the blockers I'd want addressed before merge. The aria-expanded issue is worth tracking as a follow-up issue if not addressed here.

@adnaan adnaan merged commit eb9cd54 into main Apr 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants