app/Views/Templates/components/COMPONENTS.md
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).
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.
lt-/forms.css classes. Zero visual change. We insert the abstraction
layer without touching the output.feature/ui-components).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.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 | Options | Default | Notes |
|---|---|---|---|
contentRole | default · primary · secondary · tertiary(=ghost) · accent · link | primary (actions) | semantic role |
state | default · info · warning · danger · success | default | |
variant | component-specific | '' | behavior/shape variant |
scale | xs · s · m · l · xl | m | size |
position | left · right · top · bottom · inner · outer · start · end | bottom | |
tag (element) | a · input · button · … | component-specific | polymorphic element |
align | start · end | ||
labelText | text | '' | |
labelPosition | top · left · right · bottom · inside | ||
caption | text | '' | helper text under the control |
validationText / validationState | text / state | '' | |
leadingVisual / trailingVisual | icon class | '' | |
items | array | [] | for list-driven components |
Props are camelCase in
@props(contentRole); Blade normalizescontent-role="…"attributes to the same variable, so call-sites may use either.
The canonical vocabulary maps to today's classes so output is unchanged:
| canonical | renders today | (at design time →) |
|---|---|---|
contentRole="primary" | btn btn-primary | dui-btn dui-btn-primary |
contentRole="secondary" | btn btn-secondary | … |
contentRole="default" | btn btn-default | … |
contentRole="tertiary"/ghost | btn 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.
Status: ⬜ todo · 🟡 in progress · ✅ no-op done (on master) · 🎨 design-updated. "Ref" = branch to crib the prop API from (reference only — do not merge).
| Component | Tag | Cat | Status | Ref | Notes |
|---|---|---|---|---|---|
| button | forms.button | forms | ✅ | refactor/table-component | merged #3531: no-op migration + 3-tier role model |
| text-input | forms.text-input | forms | ✅ | refactor/table-component | merged #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 |
| textarea | forms.textarea | forms | 🟡 | selectsComponentUpdates | PR #3562: thin no-op (attrs + inner-content slot); 10 plain migrated / 6 files; defer Tiptap editors (.tiptapSimple/.tiptapComplex/.wiki-editor-textarea) |
| select (native) | forms.select | forms | ⬜ | refactor/table-component | native no-op first; JS-enhanced later |
| form-field | forms.field-row | forms | ⬜ | refactor/table-component | label-row + caption + validation wrapper |
| card (content-box) | elements.card | elements | ⬜ | ui-components | replaces .maincontentinner (167 sites) |
| chip | actions.chip | actions | ⬜ | selectsComponentUpdates | |
| dropdown-menu | actions.dropdown | actions | ⬜ | refactor/table-component | JS-coupled (Bootstrap dropdown) |
| modal | actions.modal | actions | ⬜ | modal line | unify 3 legacy modal systems; HxComponent-aligned |
| tabs | navigation.tabs | navigation | ⬜ | ui-components | jQuery-UI tabs; needs htmx.onLoad re-init |
| text-editor | forms.text-editor | forms | ⬜ | (Tiptap core) | wrap Tiptap (already HTMX-aware) |
| date-picker | forms.date-picker | forms | ⬜ | selectsComponentUpdates | jQuery-UI datepicker; needs htmx.onLoad re-init |
| Component | Tag | Cat | Status | Notes |
|---|---|---|---|---|
| checkbox | forms.checkbox | forms | ⬜ | |
| radio | forms.radio | forms | ⬜ | |
| toggle | forms.toggle | forms | ⬜ | |
| button-group | forms.button-group | forms | ⬜ | |
| badge | elements.badge | elements | ⬜ | flat badge exists on master — migrate to category |
| avatar | elements.avatar | elements | ⬜ | flat avatar exists on master |
| accordion | elements.accordion | elements | ⬜ | flat accordion exists on master |
| table | elements.table | elements | ⬜ | DataTables-coupled; class-backed (Table.php) |
| empty-state | elements.empty-state | elements | ⬜ | wraps undrawSvg |
| date-info | elements.date-info | elements | ⬜ | relative-time |
| statistic / code | elements.statistic / elements.code | elements | ⬜ | |
| steps / breadcrumbs / pagination | navigation.* | navigation | ⬜ | |
| alert / progress / skeleton / loading / indicator | feedback.* | feedback | ⬜ | loader/loadingText exist on master |
| page-header | layout.page-header | layout | ⬜ | flat pageheader exists on master |
| color-picker / select-panel / context-menu | various | ⬜ |
| Component | Tag | Status | Notes |
|---|---|---|---|
| ticket-card | tickets::ticket-card | ⬜ | = the tile from refactor/card-column-components |
| ticket-column | tickets::ticket-column | ⬜ | = column from refactor/card-column-components |
| milestone-card | tickets::milestone-card | ⬜ | |
| project-card | projects::project-card | ⬜ | |
| comments list | comments::list | ⬜ | HxController-backed |
elements.card = the glass content-box that replaces .maincontentinner.refactor/card-column-components becomes tickets::ticket-card.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 | Age | Use as | Verdict |
|---|---|---|---|
feature/ui-components | fresh (Feb 2026) | richest reference: daisyUI theme, full category layer, 11/12 P0, domain cards, JS modules | reference; broke features as a big-bang — harvest APIs, don't merge |
refactor/table-component | ~2024 | best forms/table/form-field + prop IDL + Table.php | reference |
selectsComponentUpdates | Jan 2025 | superset forms incl. chip/datepicker/select + 113 call-site examples | reference |
feature/leantime-design-tokens | 2024 | daisyUI theme + Material-3 palette token values | reference (for design phase) |
modal line (feature/modal-component) | 2024 | <dialog> + hash-routed global page-modal pattern | reference (rebuild on HxComponent) |
refactor/javascript-to-modules-… | 2024 | full 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, commentsComponent | 2024 | stale/subsumed | reference at most |
Copy Tiptap (public/assets/js/app/core/tiptap/index.js) — the only widget already correct:
data-lt-* initializer attribute (never an inline <script>),WeakMap, data-…-initialized guard),htmx.onLoad (init on first paint + every swap) and, where teardown is needed,
htmx:beforeSwap/htmx:afterSwap,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.
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).<?php echo ?> / <?= ?> inside a component attribute valueRaw 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.
php bin/leantime view:cache + vendor/bin/pint --test (syntactic gate).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).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).<input type="submit"> (no class)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.btn-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.btn btn-default btn-success) — component currently emits one color; allow coexistence.<a onclick> without hrefhref only when link is set; migrate these by omitting the link prop.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).
main-title-input → variant="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%}).Only visually-distinct treatments earn a variant. Verdicts:
| variant | class | real? | what it actually is |
|---|---|---|---|
headline | .main-title-input | ✅ | large 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) | .secretInput | ✅ | inline-edit "looks like text until touched": transparent, no border/shadow, hover/focus reveal box. Pending its async-save JS migration. |
form | .form-control | ❌ removed | overridden by forms.css element selectors |
legacy | .input | ❌ removed | no .input CSS rule exists anywhere |
.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)..timepicker, type="time", #dueTime #timeFrom #timeTo.#tags (+ #tags_tag/#tags_tagsinput), .tagsinputField, data-role="tagsinput", #wikiTagsInput..secretInput, .asyncInputUpdate (+ data-label / data-id)..simpleColorPicker. honeypot: .ohnohoney..hourCell (timesheet grid), .sorter + name/id clone markers
like XXNEWKEYXX or pipe-keyed name="new|GENERAL_BILLABLE|…".class/id built with {{ }} / {!! !!} (can't statically classify → defer).<?php echo ?> / <?= ?> in an attribute value (see gotcha above).onchange / onblur / onkeyup / oninput / onfocus handler.feature/componentization branched off master; card-naming resolved.forms.button built + 2 correctness fixes (native button-type, no default color).Auth/login migrated; Playwright before/after = byte-identical (proven).link is set (so <a onclick> w/o href migrates).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.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).<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).forms.text-input built on feature/text-input-component (off master, post-#3531).
Scope + datepicker/tags/inline-edit defer rubric above. PR #3558.Projects/newProject headline (main-title-input → variant="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.)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).inputType → type: 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.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.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).<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.