Back to Leantime

Frontend Componentization — Tracker & Playbook

app/Views/Templates/components/COMPONENTS.md

3.9.625.5 KB
Original Source

Frontend Componentization — Tracker & Playbook

Owner: maintained by Claude as the single source of truth for the componentization effort. Supersedes the "Component Updates Tracker" spreadsheet (whose status column is stale — the taxonomy, naming, prop vocabulary, and priorities are kept).

Goal

Route all of Leantime's HTML through a central component layer so that a future design overhaul (e.g. daisyUI) becomes a one-file change instead of an N-thousand-call-site change.

The rules (how we do this safely)

  1. No-op first. Every component renders byte-for-byte what the page renders today — same Bootstrap/lt-/forms.css classes. Zero visual change. We insert the abstraction layer without touching the output.
  2. The prop API is the durable contract; the rendered classes are the swappable implementation. Call-sites are written against the canonical prop vocabulary (below) now. At design time, only each component's internal class-map + the CSS change — restyling the whole app from one place. This is the entire point.
  3. One component at a time, tested each step. Build no-op component → verify identical render (compile + Playwright before/after) → migrate call-sites in small batches → test → commit → next. No big-bang merges (that's what broke feature/ui-components).
  4. Defer the design engine. No daisyUI, no tw--prefix churn, no JS rewrite during the no-op phase. The design update (daisyUI or otherwise) is a later, separate phase that becomes trivial because the component layer exists.
  5. Old branches are API reference only, never a merge source (see Branch Landscape).

Taxonomy & naming

Category-namespaced anonymous Blade components — resolves today with no ServiceProvider change (nested folders already work):

<x-global::{category}.{name}>  →  app/Views/Templates/components/{category}/{name}.blade.php

Six categories: elements · forms · actions · navigation · feedback · layout. Domain-specific components live under their domain namespace, e.g. <x-tickets::ticket-card>app/Domain/Tickets/Templates/components/ticket-card.blade.php.

Prop vocabulary (the IDL — the durable contract)

PropOptionsDefaultNotes
contentRoledefault · primary · secondary · tertiary(=ghost) · accent · linkprimary (actions)semantic role
statedefault · info · warning · danger · successdefault
variantcomponent-specific''behavior/shape variant
scalexs · s · m · l · xlmsize
positionleft · right · top · bottom · inner · outer · start · endbottom
tag (element)a · input · button · …component-specificpolymorphic element
alignstart · end
labelTexttext''
labelPositiontop · left · right · bottom · inside
captiontext''helper text under the control
validationText / validationStatetext / state''
leadingVisual / trailingVisualicon class''
itemsarray[]for list-driven components

Props are camelCase in @props (contentRole); Blade normalizes content-role="…" attributes to the same variable, so call-sites may use either.

No-op mapping principle (worked example: button)

The canonical vocabulary maps to today's classes so output is unchanged:

canonicalrenders today(at design time →)
contentRole="primary"btn btn-primarydui-btn dui-btn-primary
contentRole="secondary"btn btn-secondary
contentRole="default"btn btn-default
contentRole="tertiary"/ghostbtn btn-transparent
contentRole="link"btn btn-link
state="danger"btn btn-danger
scale="s" / scale="l"btn btn-small / btn btn-large

Extra/legacy classes pass through via $attributes->merge (e.g. class="addCanvasLink"). JS-coupled buttons (dropdown-toggle) are migrated in the dropdown component phase, not here.

Component registry

Status: ⬜ todo · 🟡 in progress · ✅ no-op done (on master) · 🎨 design-updated. "Ref" = branch to crib the prop API from (reference only — do not merge).

P0 — primitives & core

ComponentTagCatStatusRefNotes
buttonforms.buttonformsrefactor/table-componentmerged #3531: no-op migration + 3-tier role model
text-inputforms.text-inputformsrefactor/table-componentmerged #3558: no-op; 146 call-sites / 56 files; variants headline/large/small (dropped form/legacy as CSS-redundant); HTML-native type prop; defer JS-coupled (datepickers/tags/inline-edit/color/sorter/hourCell) + legacy <?php echo ?>-in-attr
textareaforms.textareaforms🟡selectsComponentUpdatesPR #3562: thin no-op (attrs + inner-content slot); 10 plain migrated / 6 files; defer Tiptap editors (.tiptapSimple/.tiptapComplex/.wiki-editor-textarea)
select (native)forms.selectformsrefactor/table-componentnative no-op first; JS-enhanced later
form-fieldforms.field-rowformsrefactor/table-componentlabel-row + caption + validation wrapper
card (content-box)elements.cardelementsui-componentsreplaces .maincontentinner (167 sites)
chipactions.chipactionsselectsComponentUpdates
dropdown-menuactions.dropdownactionsrefactor/table-componentJS-coupled (Bootstrap dropdown)
modalactions.modalactionsmodal lineunify 3 legacy modal systems; HxComponent-aligned
tabsnavigation.tabsnavigationui-componentsjQuery-UI tabs; needs htmx.onLoad re-init
text-editorforms.text-editorforms(Tiptap core)wrap Tiptap (already HTMX-aware)
date-pickerforms.date-pickerformsselectsComponentUpdatesjQuery-UI datepicker; needs htmx.onLoad re-init

P1

ComponentTagCatStatusNotes
checkboxforms.checkboxforms
radioforms.radioforms
toggleforms.toggleforms
button-groupforms.button-groupforms
badgeelements.badgeelementsflat badge exists on master — migrate to category
avatarelements.avatarelementsflat avatar exists on master
accordionelements.accordionelementsflat accordion exists on master
tableelements.tableelementsDataTables-coupled; class-backed (Table.php)
empty-stateelements.empty-stateelementswraps undrawSvg
date-infoelements.date-infoelementsrelative-time
statistic / codeelements.statistic / elements.codeelements
steps / breadcrumbs / paginationnavigation.*navigation
alert / progress / skeleton / loading / indicatorfeedback.*feedbackloader/loadingText exist on master
page-headerlayout.page-headerlayoutflat pageheader exists on master
color-picker / select-panel / context-menuvarious

Domain-specific

ComponentTagStatusNotes
ticket-cardtickets::ticket-card= the tile from refactor/card-column-components
ticket-columntickets::ticket-column= column from refactor/card-column-components
milestone-cardtickets::milestone-card
project-cardprojects::project-card
comments listcomments::listHxController-backed

Card naming resolution (decided)

  • elements.card = the glass content-box that replaces .maincontentinner.
  • The small tile I shipped on refactor/card-column-components becomes tickets::ticket-card.
  • My column becomes tickets::ticket-column.
  • refactor/card-column-components is superseded — its work folds into the above; the Logic Model board will consume tickets::* + elements.card.

Branch landscape (reference only — DO NOT merge)

BranchAgeUse asVerdict
feature/ui-componentsfresh (Feb 2026)richest reference: daisyUI theme, full category layer, 11/12 P0, domain cards, JS modulesreference; broke features as a big-bang — harvest APIs, don't merge
refactor/table-component~2024best forms/table/form-field + prop IDL + Table.phpreference
selectsComponentUpdatesJan 2025superset forms incl. chip/datepicker/select + 113 call-site examplesreference
feature/leantime-design-tokens2024daisyUI theme + Material-3 palette token valuesreference (for design phase)
modal line (feature/modal-component)2024<dialog> + hash-routed global page-modal patternreference (rebuild on HxComponent)
refactor/javascript-to-modules-…2024full domain-JS ESM conversion (still pending eventually)reference
feature/card-component, feature/table-component, left-nav-design-fix, file-component, button/text-input/checkbox-radio-component, commentsComponent2024stale/subsumedreference at most

JS-backed component pattern (the standard)

Copy Tiptap (public/assets/js/app/core/tiptap/index.js) — the only widget already correct:

  • markup carries a data-lt-* initializer attribute (never an inline <script>),
  • one central idempotent registry per widget type (WeakMap, data-…-initialized guard),
  • wired to htmx.onLoad (init on first paint + every swap) and, where teardown is needed, htmx:beforeSwap/htmx:afterSwap,
  • heavy bundles lazy-loaded via Template::requireComponents([...]) / needsComponent().

This fixes the SlimSelect / Chosen / jQuery-UI-datepicker / tabs / inlineSelect bug where inline jQuery(document).ready init runs only on first paint and breaks after HTMX swaps.

⚠️ Gotcha: no double-quotes inside a component attribute value

Blade parses component attributes more strictly than plain HTML. A " inside a {{ }} expression within an attribute value terminates the attribute early and breaks the tag — even though the same markup works as a raw <a href="...">. So when migrating:

  • href="{{ $x["key"] }}" → use {{ $x['key'] }} (single-quote the array key), or :link="$x['key']".
  • href="{{ BASE_URL . "/path/$id" }}" → use link="{{ BASE_URL }}/path/{{ $id }}" (Blade interpolation).
  • class="{{ $c ? "a" : "b" }}" → single-quote the strings, or compute in @php. Run the brace/quote-aware scan (forms.button opening tags with a " inside any {{ }}) after any button migration batch — view:cache does NOT catch these (they fail at render, not compile).

⚠️ Gotcha: no legacy <?php echo ?> / <?= ?> inside a component attribute value

Raw PHP echo tags work in a plain <input placeholder="<?php echo … ?>"> (PHP executes at render), but Laravel's component-tag compiler treats a non-bound attribute value as a literal string, so <?php … ?> inside a <x-…> attribute does NOT reliably execute. Leave such inputs RAW (or first modernize the echo to {{ … }} / {!! $tpl->escape(…) !!} in a separate step, then migrate). Found in Auth/userInvite (placeholders use <?php echo $tpl->language->__('…') ?>) — deferred. Scan migrated tags for <?php / <?= before committing.

Per-component playbook (repeatable)

  1. Read what the primitive renders today (classes, JS hooks, every call-site shape).
  2. Build the no-op component under the right category, full prop IDL, mapping to today's classes.
  3. php bin/leantime view:cache + vendor/bin/pint --test (syntactic gate).
  4. Migrate a small pilot batch of call-sites; Playwright before/after to prove zero visual diff.
  5. Migrate the rest in batches, re-verifying; commit per batch.
  6. Update this tracker (status, gotchas, call-site count migrated).

Button migration — deferral backlog (handle in later passes)

The no-op migration deliberately defers buttons it can't migrate without changing the rendered class set / behavior. Categories found (to revisit, some need a design decision):

  • class="button" (not btn) — DONE (#3563): a CSS audit found .button has no rule at all; input[type='submit'] is styled by the .btn-primary element-selector group (forms.css:313), so these 44 submits already render as primary buttons. Migrated all 44 to <x-global::forms.button tag="input" inputType="submit" contentRole="primary"> (no-op). Also cleaned up a few pre-existing duplicate class="button" class="button" attrs. Follow-up: ~16 are del* confirmation submits that look primary today — candidates for state="danger" in a later semantic pass (a visual change, not a no-op).
  • Unstyled <input type="submit"> (no class) — DONE (#3564, round 2): NOT a design change after all — input[type='submit'] is in the .btn-primary element-selector group (forms.css:313), so bare submits already looked primary. Migrated to contentRole="primary" (~30 of them). Intended visual no-op, not strictly byte-identical: the component adds the shared .btn base (input.btn { vertical-align: top; … }) which a bare submit lacked — imperceptible, but worth stating precisely.
  • Unmapped btn variantsbtn-sm/btn-lg (vs Leantime btn-small/btn-large), btn-danger-outline, btn-circle, btn-inverse, btn-file. Add mappings (after confirming CSS) or keep deferred.
  • role+state combo (btn btn-default btn-success) — component currently emits one color; allow coexistence.
  • <a onclick> without href — DONE: component emits href only when link is set; migrate these by omitting the link prop.
  • dropdown-toggle / data-toggle / fileupload / span.btn — handled in the dropdown / file-upload / later phases.

Text-input migration — scope & defer rubric

forms.text-input is a thin no-op: it emits a plain <input> with today's class (default = no class) and passes all attributes through; the label/validation IDL props are declared but not rendered (a wrapper would change markup — that's the design phase). Pass the HTML-native type= (it is a declared @prop, so Blade extracts it from the attribute bag — emits exactly one type, never a duplicate).

  • Migrate (146 done in PR #3558; more in follow-ups): standard inputs (bare), headline title inputs (main-title-inputvariant="headline"), search inputs. Map source class → variant; any extra non-variant class (tw-utilities, pull-left, …) passes through class=. .form-control AND .input → bare (NOT variants): both are pure Bootstrap cruft — forms.css element selectors override .form-control, and .input has no backing CSS rule at all; a bare input renders identically (the entry-page width that .form-control gave comes from .regpanelinner input{width:100%}).

Variant taxonomy (evidence-backed — 4-agent CSS audit)

Only visually-distinct treatments earn a variant. Verdicts:

variantclassreal?what it actually is
headline.main-title-inputlarge 24/26px (--font-size-xxxl) title font + box-shadow:none; keeps border/bg
large.input-large✅ (width-only)fixed width:210px — forms.css never sets width, so it survives
small.input-small✅ (width-only)fixed width:90px
ghost (planned).secretInputinline-edit "looks like text until touched": transparent, no border/shadow, hover/focus reveal box. Pending its async-save JS migration.
form.form-control❌ removedoverridden by forms.css element selectors
legacy.input❌ removedno .input CSS rule exists anywhere
  • Leave RAW — do-not-touch signals (JS-coupled; breaking these regresses behavior):
    • datepickers (jQuery-UI): .dates .duedates .quickDueDates .dateFrom .dateTo .editFrom .editTo .startDate .endDate .projectDateFrom .projectDateTo .week-picker .hasDatepicker + ids #deadline #sprintStart #sprintEnd #event_date_* #date #startDate #endDate #timesheetdate #invoiced* #paidDate (many init via inline <script> in the template + an a11y pass on .hasDatepicker).
    • time: .timepicker, type="time", #dueTime #timeFrom #timeTo.
    • tags: #tags (+ #tags_tag/#tags_tagsinput), .tagsinputField, data-role="tagsinput", #wikiTagsInput.
    • inline-edit / async-save: .secretInput, .asyncInputUpdate (+ data-label / data-id).
    • color: .simpleColorPicker. honeypot: .ohnohoney.
    • JS grids / clone-templates: .hourCell (timesheet grid), .sorter + name/id clone markers like XXNEWKEYXX or pipe-keyed name="new|GENERAL_BILLABLE|…".
    • dynamic class/id built with {{ }} / {!! !!} (can't statically classify → defer).
    • legacy <?php echo ?> / <?= ?> in an attribute value (see gotcha above).
    • any inline onchange / onblur / onkeyup / oninput / onfocus handler.

Progress log

  • Phase 0: tracker created; feature/componentization branched off master; card-naming resolved.
  • button: no-op forms.button built + 2 correctness fixes (native button-type, no default color).
  • button pilot: Auth/login migrated; Playwright before/after = byte-identical (proven).
  • button batch 1: ~65 plain buttons migrated across 46 core form/admin/CRUD templates (9-agent fan-out, disjoint files); ~70 deferred per the backlog above. Verified: view:cache compiles, audit shows no JS-coupled class swallowed, real before/after on /users/showAll = identical class set.
  • button href tweak: component emits href only when link is set (so <a onclick> w/o href migrates).
  • button batch 2: ~100 plain buttons migrated across 43 JS-heavy templates (Tickets, Dashboard, Widgets, Canvas/Blueprints/Goalcanvas/Logicmodel, Ideas, Wiki, Calendar, Sprints); the rest deferred (dropdown-toggles, fc-* calendar, file-uploads, class="button", unmapped variants, role+state). Verified: compile clean, audit clean, live no-op spot-check on /goalcanvas/showCanvas. Core plain-button migration is now essentially complete — remaining work = the deferral backlog (dropdowns get migrated in the dropdown-component phase; class="button"/unstyled = design decisions).
  • button role sanity pass: 15 Back/Cancel/"Go Back" buttons that were hard-coded btn-primary in the original markup demoted to contentRole="secondary" (alternative/navigate-away actions). Only the role VALUE changed. This is intentionally NOT a no-op (appearance changes; secondary is unstyled until the design phase).
  • button role promotions: 5 main-action submits that were default promoted to primary for consistency with siblings — Ideas board create/save (advancedBoards + showBoards, ×4) and the Comments/showAll reply (generalComment's reply was already primary). Genuinely-secondary default buttons (Back, Export, Copy, Reset Logo, Resend Invite, Close, Activate) left as-is.
  • button outline variant: added variant="outline" to forms.button (emits btn-outline / btn-{state}-outline). All "Save & Close" buttons set to variant="outline" to match the edit-ticket save style (7 sites: 5 canvas/idea dialogs + the ticketDetails/articleDialog inputs componentized).
  • action-links -> secondary: ~35 standalone Cancel/Back/Close/Delete/Remove links that were bare <a> text-links (no btn class) converted to <x-global::forms.button ... contentRole="secondary">, preserving onclick + JS-hook classes (delete/formModal/editTimeModal/...). Strictly skipped: dropdown <li> menu-items (incl. menu delete/edit), accordion + inline |-separated toggles, add/create toggles, nav, timers, and already-btn links. Still bare (flagged, not converted): inline per-comment deleteComment links + per-row table delete actions (would need a smaller-scale/inline treatment).
  • text-input: thin no-op forms.text-input built on feature/text-input-component (off master, post-#3531). Scope + datepicker/tags/inline-edit defer rubric above. PR #3558.
  • text-input pilot: Projects/newProject headline (main-title-inputvariant="headline") migrated; Playwright = byte-identical (same class/type/name/id/style/value/placeholder); the two .dateFrom/.dateTo datepickers on the same page left RAW (component never applied to JS-coupled inputs → can't regress). (Note: dev instance currently isn't loading compiled-app/jQuery, so runtime datepicker init couldn't be exercised — but the datepicker DOM is byte-identical to master since those lines are untouched.)
  • text-input sweep: 146 call-sites across 56 files migrated (63-file 2-phase workflow: per-file migrate
    • adversarial diff-verify; all 63 verified ok). Diff is perfectly symmetric (202 ins / 202 del = pure in-place swaps). Static audit of all 146: 0 problems (no type=/inputType dup, no variant class left in class=, no JS-coupled signal swallowed, no nested-quote, no dup attrs). Compile + Pint clean. Live render no-op confirmed on /setting/editCompanySettings (pull-left passthrough) + /clients/newClient (bare). Deferred to follow-ups: Auth/userInvite (3 inputs w/ legacy <?php echo ?> in attrs — see gotcha), Tickets/partials/ticketCard + partials/subtasks (HTMX inline-edit/date), and everywhere the do-not-touch signals (datepickers/tags/inline-edit/color/sorter/hourCell/dynamic-class).
  • text-input API refinement (review feedback): two API cleanups after review. (1) inputTypetype: renamed the prop to the HTML-native type (17 call-sites). It's a declared @prop, so Blade extracts it from the attribute bag → exactly one type, no duplication. (forms.button keeps inputType because it's polymorphic — type is ambiguous across a/button/input.) (2) dropped variant="form" (the form/bordered.form-control arm). 3-agent CSS audit proved .form-control is cosmetically redundant in Leantime: forms.css element selectors (input[type=text]…, loaded after Bootstrap) override its bg/border/radius/shadow/padding/height/color, and the only residual effect (desktop width:100%) is already supplied by container rules (.regpanelinner input{width:100%}) for the sole 7 call-sites (login ×2, twoFA/verify ×1, install ×4 — all entry pages). No JS hooks .form-control on inputs. Collapsed those 7 to bare; live render on /auth/login = bare inputs, single type, no form-control. Bare IS the form look now.
  • text-input variant taxonomy (review feedback): 4-agent CSS audit to keep ONLY evidence-backed variants. Findings: headline(.main-title-input) = REAL (large --font-size-xxxl font + shadow removed); large(.input-large)/small(.input-small) = REAL but width-only (210px/90px — the one prop forms.css doesn't set); ghost(.secretInput) = REAL inline-edit treatment (4 distinct low-chrome looks found, the canonical one being .secretInput) but its call-sites are the deferred async-save fields, so it's a planned variant; legacy(.input) = REDUNDANT (no .input CSS rule exists anywhere). Dropped variant="legacy" (1 call-site, TwoFA/edit → bare; removed the arm). Component now exposes only headline/large/small.
  • textarea: thin no-op forms.textarea (#3562). Body is <textarea {{ $attributes }}>{{ $slot }}</textarea> — attributes pass through, the field value is the slot (inner content) preserved EXACTLY (textareas are whitespace-sensitive). 10 plain textareas migrated across 6 files (Help projectDefinitionStep ×3, Ideas/Wiki newMilestone, Timesheets add/edit + Tickets timesheet description, Widgets myToDos description-input ×2). 19 Tiptap editor textareas left RAW — JS upgrades exactly textarea.tiptapSimple / textarea.tiptapComplex (core/tiptap/index.js) plus the Wiki .wiki-editor-textarea; never route those through the component. No variant arm (plain textareas carry no distinct style class; the only textarea classes are editor-coupled).
  • button + text-input completion (round 2): swept blade for buttons/inputs missed by #3531/#3558. 53 migrated across 38 files: 29 bare <input type=submit> (no class — already looked primary via forms.css:313, so contentRole="primary" is an intended visual no-op; the .btn base adds minor props like vertical-align, imperceptible), 4 token-UI text inputs/buttons, Errors back ×4, support sponsor, Auth token UI (create/copy/close/delete), Files cancel ×2, widgetManager reset (btn-outline→secondary), Reports chart toggles ×6, showProject delete (btn-danger-outline→state=danger variant=outline), 1 comment reply. btn-sm/btn-lg/btn-secondary (own CSS, ≠ Leantime's small/large/outline) passed through class= pending a design-phase scale/role mapping. Left deferred (correct): 3 comment btn-success role+state combos (component emits one color); partials/subtasks quickadd (nested __("…") + HTMX file); dynamic-class links (calendarSettings, Dashboard favoriteProject); ticketFilter raw <a> (whitespace-sensitive, intentional); custom non-btn widget buttons (Wiki collapse/panel, calendar day-button, todoItem reset); modal data-dismiss/.close, Files .delete icons, file-upload picSubmit, dropdown-toggles, <?php echo invite variants. Verified: compile + Pint clean, 0 button-tag problems, diff is tag swaps (multiline tags collapse to 1 line). ALSO: TimesheetCest selectors that clicked .button repointed to input[type=submit]/name (the .button class is removed by the migration) — see #3563.