diff --git a/qml/components/core/CButton.qml b/qml/components/core/CButton.qml index a47de38ee..181efbb83 100644 --- a/qml/components/core/CButton.qml +++ b/qml/components/core/CButton.qml @@ -4,115 +4,149 @@ import QtQuick.Layouts import QmlComponents 1.0 /** - * CButton.qml - Styled button component (mirrors _button.scss) - * Uses StyleVariables for consistent sizing and spacing + * CButton.qml - Material Design 3 button component + * + * Variants: + * primary - Filled button (pill, Theme.primary bg, white text) + * default - Tonal button (pill, 12% primary tint bg) + * secondary - Tonal button (pill, 12% primary tint bg) + * ghost - Outlined button (pill, border, transparent bg) + * outlined - Outlined button (alias for ghost) + * text - Text button (no border, no bg, colored text) + * danger - Filled danger button (pill, Theme.error bg) */ Button { id: control - - property string variant: "default" // default, primary, secondary, ghost, danger, text + + property string variant: "default" // default, primary, secondary, ghost, outlined, danger, text property string size: "md" // sm, md, lg property string iconSource: "" - property string iconText: "" // Alias for simpler icon usage (emoji/text icons) + property string iconText: "" property bool loading: false - - // Effective icon: prefer iconText over iconSource + readonly property string _effectiveIcon: iconText || iconSource - - // Use StyleVariables for sizing (mirrors _button.scss) + + // MD3 sizing: sm=32, md=40, lg=48 implicitHeight: { switch (size) { - case "sm": return StyleVariables.buttonSizes.sm.height - case "lg": return StyleVariables.buttonSizes.lg.height - default: return StyleVariables.buttonSizes.md.height + case "sm": return 32 + case "lg": return 48 + default: return 40 } } - + implicitWidth: Math.max(implicitHeight, contentRow.implicitWidth + _paddingH * 2) - + readonly property int _paddingH: { switch (size) { - case "sm": return StyleVariables.buttonSizes.sm.paddingH - case "lg": return StyleVariables.buttonSizes.lg.paddingH - default: return StyleVariables.buttonSizes.md.paddingH + case "sm": return 16 + case "lg": return 28 + default: return 24 } } - - font.pixelSize: { - switch (size) { - case "sm": return StyleVariables.buttonSizes.sm.fontSize - case "lg": return StyleVariables.buttonSizes.lg.fontSize - default: return StyleVariables.buttonSizes.md.fontSize - } + + font.pixelSize: 14 + font.weight: Font.DemiBold + + // Resolve whether this variant is filled, outlined, tonal, or text + readonly property bool _isFilled: variant === "primary" || variant === "danger" + readonly property bool _isOutlined: variant === "ghost" || variant === "outlined" + readonly property bool _isText: variant === "text" + readonly property bool _isTonal: variant === "default" || variant === "secondary" + + // Base fill color (before state layers) + readonly property color _baseFill: { + if (variant === "primary") return Theme.primary + if (variant === "danger") return Theme.error + if (_isTonal) return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + return "transparent" // ghost, outlined, text } - font.weight: Font.Medium - + + // Foreground / text color + readonly property color _foreground: { + if (!enabled) return Theme.textDisabled + if (variant === "primary") return "#ffffff" + if (variant === "danger") return "#ffffff" + return Theme.primary + } + + opacity: enabled ? 1.0 : 0.38 + Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } } + background: Rectangle { - radius: StyleVariables.radiusSm + radius: control.height / 2 + + // State layer: hover = 8% overlay, pressed = 12% overlay + readonly property color _stateLayer: { + if (control._isFilled) return "#ffffff" // white overlay on filled + return Theme.primary // primary overlay on others + } + color: { - if (!control.enabled) return Theme.surface + if (!control.enabled) { + if (control._isFilled) return Theme.surface + return "transparent" + } if (control.down) { - switch(control.variant) { - case "primary": return Qt.darker(Theme.primary, 1.3) - case "secondary": return Qt.darker(Theme.success, 1.3) - case "danger": return Qt.darker(Theme.error, 1.3) - case "ghost": - case "text": return StyleMixins.activeBg(Theme.mode === "dark") - default: return Qt.darker(Theme.surface, 1.2) - } + if (control._isFilled) + return Qt.rgba(_stateLayer.r, _stateLayer.g, _stateLayer.b, 0.12) + // blend: we layer 12% on top of base + // For filled, darken slightly instead + if (control._isTonal) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.22) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) } if (control.hovered) { - switch(control.variant) { - case "primary": return Qt.darker(Theme.primary, 1.1) - case "secondary": return Qt.darker(Theme.success, 1.1) - case "danger": return Qt.darker(Theme.error, 1.1) - case "ghost": - case "text": return StyleMixins.hoverBg(Theme.mode === "dark") - default: return Qt.lighter(Theme.surface, 1.1) - } - } - switch(control.variant) { - case "primary": return Theme.primary - case "secondary": return Theme.success - case "danger": return Theme.error - case "ghost": return "transparent" - case "text": return "transparent" - default: return Theme.surface + if (control._isFilled) + return control._baseFill // overlay handled by stateOverlay + if (control._isTonal) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.18) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) } + return control._baseFill + } + + border.width: control._isOutlined ? 1 : 0 + border.color: control.enabled ? Theme.border : Theme.actionDisabled + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + // State layer overlay for filled buttons (hover/press) + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: control._isFilled && control.enabled && (control.hovered || control.down) + color: control.variant === "danger" ? "#000000" : "#ffffff" + opacity: control.down ? 0.12 : (control.hovered ? 0.08 : 0) + + Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } } } - border.width: control.variant === "ghost" ? 1 : 0 - border.color: Theme.border - - Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } } - + contentItem: RowLayout { id: contentRow - spacing: StyleVariables.spacingSm - + spacing: 8 + BusyIndicator { Layout.preferredWidth: 16 Layout.preferredHeight: 16 running: control.loading visible: control.loading } - + Text { - visible: control._effectiveIcon && !control.loading + visible: control._effectiveIcon !== "" && !control.loading text: control._effectiveIcon font.pixelSize: control.font.pixelSize - color: control.enabled ? Theme.text : Theme.textDisabled + color: control._foreground } - + Text { text: control.text font: control.font - color: control.enabled ? Theme.text : Theme.textDisabled + color: control._foreground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } - - Behavior on opacity { NumberAnimation { duration: StyleVariables.transitionFast } } - opacity: enabled ? 1.0 : 0.5 } diff --git a/qml/components/core/CCard.qml b/qml/components/core/CCard.qml index fd853b845..33449f869 100644 --- a/qml/components/core/CCard.qml +++ b/qml/components/core/CCard.qml @@ -5,48 +5,75 @@ import QtQuick.Effects import QmlComponents 1.0 /** - * CCard.qml - Card container component (mirrors _card.scss) - * Uses StyleVariables for consistent styling + * CCard.qml - Material Design 3 Card component + * + * Variants: + * "filled" (default) - surfaceContainerHigh fill, subtle border + * "outlined" - transparent fill, Theme.border stroke + * "elevated" - surface color + drop shadow */ Rectangle { id: card - + + // ── Public API (preserved) ────────────────────────────────────── property string title: "" property string subtitle: "" property bool elevated: false property bool hoverable: false property bool clickable: false - property string variant: "default" // default, outlined, elevated - + property string variant: "filled" // filled, outlined, elevated + signal clicked() - + default property alias cardContent: contentColumn.data - - color: Theme.paper - radius: StyleVariables.radiusMd - border.width: 1 - border.color: { - if (hoverable && mouseArea.containsMouse) return Theme.primary - return variant === "outlined" ? Theme.border : Theme.border - } - - implicitHeight: contentColumn.implicitHeight + + // ── MD3 surface tints ─────────────────────────────────────────── + readonly property bool isDark: Theme.mode === "dark" + + readonly property color surfaceContainer: + isDark ? Qt.rgba(1, 1, 1, 0.05) : Qt.rgba(0.31, 0.31, 0.44, 0.06) + readonly property color surfaceContainerHigh: + isDark ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0.31, 0.31, 0.44, 0.10) + + // ── Geometry ──────────────────────────────────────────────────── + radius: 12 implicitWidth: 300 - + implicitHeight: contentColumn.implicitHeight + + // ── Fill colour per variant ───────────────────────────────────── + color: { + switch (variant) { + case "outlined": return "transparent" + case "elevated": return Theme.surface + default: return surfaceContainerHigh // filled + } + } + + // ── Border ────────────────────────────────────────────────────── + border.width: variant === "elevated" ? 0 : 1 + border.color: { + if ((hoverable || clickable) && mouseArea.containsMouse) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5) + if (variant === "outlined") return Theme.border + // filled: subtle border + return isDark ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.08) + } + Behavior on border.color { ColorAnimation { duration: StyleVariables.transitionFast } } - Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } - - // Hover effect for clickable cards + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } + + // ── Hover overlay ─────────────────────────────────────────────── Rectangle { anchors.fill: parent radius: parent.radius - color: (card.hoverable || card.clickable) && mouseArea.containsMouse - ? StyleMixins.hoverBg(Theme.mode === "dark") + color: (card.hoverable || card.clickable) && mouseArea.containsMouse + ? StyleMixins.hoverBg(card.isDark) : "transparent" - + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } } - + + // ── Elevation (shadow) ────────────────────────────────────────── layer.enabled: elevated || variant === "elevated" layer.effect: MultiEffect { shadowEnabled: true @@ -54,7 +81,8 @@ Rectangle { shadowBlur: StyleVariables.shadowMd.blur shadowVerticalOffset: StyleVariables.shadowMd.offset } - + + // ── Interaction ───────────────────────────────────────────────── MouseArea { id: mouseArea anchors.fill: parent @@ -62,11 +90,14 @@ Rectangle { cursorShape: card.clickable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: if (card.clickable) card.clicked() } - - // Simple content column - children are placed here + + // ── Content column with MD3 16 px padding ────────────────────── ColumnLayout { id: contentColumn - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 spacing: 0 } } diff --git a/qml/components/core/CChip.qml b/qml/components/core/CChip.qml index 37e98eabe..a2a87077e 100644 --- a/qml/components/core/CChip.qml +++ b/qml/components/core/CChip.qml @@ -4,92 +4,168 @@ import QtQuick.Layouts import QmlComponents 1.0 /** - * CChip.qml - Chip/tag component (mirrors _chip.scss) - * Uses StyleVariables and StyleMixins for consistent styling + * CChip.qml - Material Design 3 Chip component + * + * Variants: + * assist - Outlined, transparent fill (default) + * filter - Tonal fill when selected, outlined when not + * input - Outlined with optional close icon + * suggestion - Outlined, similar to assist + * + * Status variants (legacy compat): success, warning, error, info, primary */ Rectangle { id: chip - + property string text: "" property string icon: "" - property string variant: "default" // default, success, warning, error, info, primary, outlined - property string size: "sm" // sm, md + property string variant: "assist" // assist, filter, input, suggestion, success, warning, error, info, primary, outlined + property string size: "md" // sm, md property bool clickable: false property bool closable: false property bool checked: false - + property bool selected: false + property color color: Theme.primary + signal clicked() signal closeClicked() - - // Use StyleVariables for sizing - implicitHeight: size === "sm" ? StyleVariables.chipSizes.sm.height : StyleVariables.chipSizes.md.height - implicitWidth: chipRow.implicitWidth + (size === "sm" ? StyleVariables.chipSizes.sm.paddingH : StyleVariables.chipSizes.md.paddingH) * 2 - radius: StyleVariables.radiusFull - - // Use StyleMixins for status colors - color: { - if (variant === "outlined") return "transparent" - switch(variant) { - case "success": return StyleMixins.statusBgColor("success") - case "warning": return StyleMixins.statusBgColor("warning") - case "error": return StyleMixins.statusBgColor("error") - case "info": return StyleMixins.statusBgColor("info") - case "primary": return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) - default: return Theme.surface - } - } - - border.width: variant === "outlined" ? 1 : 0 - border.color: _chipColor - - readonly property color _chipColor: { - switch(variant) { - case "success": return StyleMixins.statusColor("success") - case "warning": return StyleMixins.statusColor("warning") - case "error": return StyleMixins.statusColor("error") - case "info": return StyleMixins.statusColor("info") + + // MD3: 32px height + implicitHeight: 32 + implicitWidth: chipRow.implicitWidth + _paddingH * 2 + + readonly property real _paddingH: size === "sm" ? 12 : 16 + + // MD3: 8px radius (not full pill) + radius: 8 + + // Resolve the effective color for status variants + readonly property color _resolvedColor: { + switch (variant) { + case "success": return Theme.success + case "warning": return Theme.warning + case "error": return Theme.error + case "info": return Theme.info case "primary": return Theme.primary - default: return Theme.textSecondary + default: return color } } - + + readonly property bool _isStatusVariant: { + return variant === "success" || variant === "warning" || + variant === "error" || variant === "info" || variant === "primary" + } + + // MD3 fill logic + color: { + // Status variants: tonal fill + if (_isStatusVariant) { + return Qt.rgba(_resolvedColor.r, _resolvedColor.g, _resolvedColor.b, 0.12) + } + switch (variant) { + case "filter": + // Selected filter chips get tonal fill + if (selected || checked) { + return Qt.rgba(_resolvedColor.r, _resolvedColor.g, _resolvedColor.b, 0.12) + } + return "transparent" + case "assist": + case "input": + case "suggestion": + case "outlined": + default: + return "transparent" + } + } + + // MD3 border: 1px outline for non-selected, no border for tonal/selected + border.width: { + if (_isStatusVariant) return 0 + if (variant === "filter" && (selected || checked)) return 0 + return 1 + } + border.color: { + if (_isStatusVariant) return _resolvedColor + return Theme.border + } + + // MD3 text/icon color + readonly property color _contentColor: { + if (_isStatusVariant) return _resolvedColor + if (variant === "filter" && (selected || checked)) return _resolvedColor + return Theme.text + } + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } - + Behavior on border.width { NumberAnimation { duration: StyleVariables.transitionFast } } + + // Hover/press overlay + Rectangle { + anchors.fill: parent + radius: parent.radius + color: chip._contentColor + opacity: mouseArea.containsPress ? 0.12 : mouseArea.containsMouse ? 0.08 : 0 + Behavior on opacity { NumberAnimation { duration: 100 } } + } + MouseArea { + id: mouseArea anchors.fill: parent hoverEnabled: chip.clickable cursorShape: chip.clickable ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: if (chip.clickable) chip.clicked() } - + RowLayout { id: chipRow anchors.centerIn: parent spacing: StyleVariables.spacingXs - + + // Leading icon Text { text: chip.icon - font.pixelSize: size === "sm" ? StyleVariables.chipSizes.sm.fontSize : StyleVariables.chipSizes.md.fontSize - color: chip._chipColor - visible: chip.icon + font.pixelSize: 18 + color: chip._contentColor + visible: chip.icon !== "" } - + + // Check icon for selected filter chips + Text { + text: "\u2713" + font.pixelSize: 14 + font.weight: Font.Bold + color: chip._contentColor + visible: chip.variant === "filter" && (chip.selected || chip.checked) + } + + // Label Text { text: chip.text - font.pixelSize: size === "sm" ? StyleVariables.chipSizes.sm.fontSize : StyleVariables.chipSizes.md.fontSize + font.pixelSize: 13 font.weight: Font.Medium - color: chip._chipColor + color: chip._contentColor } - - Text { - text: "✕" - font.pixelSize: size === "sm" ? StyleVariables.fontSizeXs : StyleVariables.fontSizeSm - color: Qt.darker(chip._chipColor, 1.2) - visible: chip.closable - + + // Close/remove icon for input chips or closable chips + Rectangle { + width: 18 + height: 18 + radius: 9 + color: closeMouseArea.containsMouse ? Qt.rgba(chip._contentColor.r, chip._contentColor.g, chip._contentColor.b, 0.12) : "transparent" + visible: chip.closable || chip.variant === "input" + + Text { + anchors.centerIn: parent + text: "\u2715" + font.pixelSize: 11 + font.weight: Font.Medium + color: chip._contentColor + } + MouseArea { + id: closeMouseArea anchors.fill: parent - anchors.margins: -4 + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: chip.closeClicked() } diff --git a/qml/components/core/CFab.qml b/qml/components/core/CFab.qml index 197daf1ad..c237224fb 100644 --- a/qml/components/core/CFab.qml +++ b/qml/components/core/CFab.qml @@ -2,27 +2,55 @@ import QtQuick import QmlComponents 1.0 /** - * CFab.qml - Floating action button + * CFab.qml - Material Design 3 Floating Action Button + * + * MD3 FAB: 56px, rounded corner (radius 16), tonal surface with primary icon. + * Hover adds 8% state layer, press adds 12% state layer. */ Rectangle { id: root + property alias icon: iconLabel.text property int size: 56 + signal clicked() width: size height: size - radius: size/2 - color: Theme.primary + radius: 16 anchors.margins: StyleVariables.spacingMd + // MD3 tonal surface: primary at 12% opacity over surface + color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + // State layer overlay for hover/press + Rectangle { + id: stateLayer + anchors.fill: parent + radius: parent.radius + color: Theme.primary + opacity: mouseArea.pressed ? 0.12 : (mouseArea.containsMouse ? 0.08 : 0) + visible: opacity > 0 + + Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } } + } + Text { id: iconLabel anchors.centerIn: parent text: "+" - color: Theme.onPrimary - font.pixelSize: size * 0.5 + color: Theme.primary + font.pixelSize: root.size * 0.43 + font.weight: Font.DemiBold } - MouseArea { anchors.fill: parent; onClicked: root.clicked() } + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } } diff --git a/qml/components/core/CIconButton.qml b/qml/components/core/CIconButton.qml index 05749fbc8..932d50ba5 100644 --- a/qml/components/core/CIconButton.qml +++ b/qml/components/core/CIconButton.qml @@ -2,48 +2,58 @@ import QtQuick import QtQuick.Controls import QmlComponents 1.0 +/** + * CIconButton.qml - Material Design 3 Icon Button + * + * MD3 icon button: 40px circle, transparent background, hover/press state layers. + * Variants: + * default - transparent bg, icon in textSecondary, hover state layer + * primary - transparent bg, icon in primary, hover state layer in primary tint + * ghost - same as default (backward compat) + */ Item { id: control - + property string icon: "" property string size: "md" // sm, md, lg property string variant: "default" // default, primary, ghost property bool loading: false property string tooltip: "" - + signal clicked() - + width: size === "sm" ? 32 : size === "lg" ? 48 : 40 height: width - + + opacity: enabled ? 1.0 : 0.38 + Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } } + Rectangle { id: bg anchors.fill: parent radius: width / 2 - color: { - if (!control.enabled) return "transparent" - if (mouseArea.pressed) { - switch(control.variant) { - case "primary": return Qt.darker(Theme.primary, 1.2) - default: return "#404040" - } - } - if (mouseArea.containsMouse) { - switch(control.variant) { - case "primary": return Theme.primary - default: return "#3d3d3d" - } - } - switch(control.variant) { - case "primary": return Theme.primary - case "ghost": return "transparent" - default: return Theme.surface + color: "transparent" + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + // State layer overlay + Rectangle { + id: stateLayer + anchors.fill: parent + radius: parent.radius + color: control.variant === "primary" ? Theme.primary : (Theme.mode === "dark" ? "#ffffff" : "#000000") + opacity: { + if (!control.enabled) return 0 + if (mouseArea.pressed) return 0.12 + if (mouseArea.containsMouse) return 0.08 + return 0 } + visible: opacity > 0 + + Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } } } - - Behavior on color { ColorAnimation { duration: 150 } } } - + BusyIndicator { anchors.centerIn: parent width: parent.width * 0.5 @@ -51,15 +61,20 @@ Item { running: control.loading visible: control.loading } - + Text { anchors.centerIn: parent text: control.icon - font.pixelSize: control.size === "sm" ? 14 : control.size === "lg" ? 22 : 18 - color: control.enabled ? (control.variant === "primary" ? "#ffffff" : Theme.textSecondary) : Theme.textDisabled + font.pixelSize: control.size === "sm" ? 16 : control.size === "lg" ? 24 : 20 + font.weight: Font.DemiBold + color: { + if (!control.enabled) return Theme.textDisabled + if (control.variant === "primary") return Theme.primary + return Theme.textSecondary + } visible: !control.loading } - + MouseArea { id: mouseArea anchors.fill: parent @@ -67,11 +82,8 @@ Item { cursorShape: Qt.PointingHandCursor onClicked: control.clicked() } - + ToolTip.visible: tooltip && mouseArea.containsMouse ToolTip.text: tooltip ToolTip.delay: 500 - - opacity: enabled ? 1.0 : 0.5 - Behavior on opacity { NumberAnimation { duration: 150 } } } diff --git a/qml/components/data-display/CBadge.qml b/qml/components/data-display/CBadge.qml index cd4ed9ca7..ea1217a5a 100644 --- a/qml/components/data-display/CBadge.qml +++ b/qml/components/data-display/CBadge.qml @@ -2,54 +2,60 @@ import QtQuick import QmlComponents 1.0 /** - * CBadge.qml - Notification badge (mirrors _badge.scss) - * Small indicator for counts or status + * CBadge.qml - Material Design 3 Badge + * + * Small: 6px dot indicator, no text + * Standard: 16px height pill with count/text, radius 8 + * Large: 20px for bigger counts + * + * MD3 default color: Theme.error (notification badge) */ Rectangle { id: root - - property string size: "md" // sm, md, lg - property string variant: "primary" // primary, success, warning, error - property int count: 0 // Number to display (0 = dot only) - property bool dot: false // Show as dot without number - property string text: "" // Direct text label (overrides count) - - // Size mapping - readonly property var _sizes: ({ - sm: { minWidth: 14, height: 14, fontSize: 9, padding: 3 }, - md: { minWidth: 16, height: 16, fontSize: 10, padding: 4 }, - lg: { minWidth: 20, height: 20, fontSize: 11, padding: 5 } - }) - - readonly property var _sizeConfig: _sizes[size] || _sizes.md - - // Color mapping + + property string text: "" + property bool accent: false + property color color: accent ? Theme.primary : Theme.error + property int count: 0 + property bool dot: false + property string variant: "primary" // primary, success, warning, error (legacy compat) + + // Resolve badge color from variant or explicit color readonly property color _bgColor: { + if (accent) return Theme.primary switch (variant) { case "success": return Theme.success case "warning": return Theme.warning case "error": return Theme.error - default: return Theme.primary + default: return color } } - + + // MD3: white text on badge, dark text on warning readonly property color _textColor: variant === "warning" ? "#000000" : "#ffffff" - - // Sizing - readonly property bool _hasText: text !== "" - width: dot ? _sizeConfig.height : Math.max(_sizeConfig.minWidth, label.implicitWidth + _sizeConfig.padding * 2) - height: _sizeConfig.height - radius: height / 2 + + // Display string + readonly property string _displayText: { + if (text !== "") return text + if (count > 99) return "99+" + return count.toString() + } + + readonly property bool _showText: !dot && (text !== "" || count > 0) + + // MD3 small badge: 6px dot, standard badge: 16px pill + width: dot ? 6 : Math.max(16, label.implicitWidth + 8) + height: dot ? 6 : 16 + radius: dot ? 3 : 8 color: _bgColor - - // Badge text + Text { id: label anchors.centerIn: parent - text: root._hasText ? root.text : (root.count > 99 ? "99+" : root.count.toString()) + text: root._displayText color: root._textColor - font.pixelSize: root._sizeConfig.fontSize - font.weight: Font.DemiBold - visible: !root.dot && (root._hasText || root.count > 0) + font.pixelSize: 11 + font.weight: Font.Bold + visible: root._showText } } diff --git a/qml/components/data-display/CStatBadge.qml b/qml/components/data-display/CStatBadge.qml index 8d37f8107..fd427b1b6 100644 --- a/qml/components/data-display/CStatBadge.qml +++ b/qml/components/data-display/CStatBadge.qml @@ -3,82 +3,96 @@ import QtQuick.Layouts import QmlComponents 1.0 /** - * CStatBadge.qml - Statistic badge (mirrors _stat-badge.scss) - * Display a statistic with label + * CStatBadge.qml - Material Design 3 Statistic Badge + * + * Large stat display with label, value, and optional icon. + * Uses tonal surface container following MD3 color system. */ Rectangle { id: root - + property string label: "" property string value: "" property string icon: "" - property string variant: "default" // default, primary, success, warning, error - property string size: "md" // sm, md, lg - - // Color mapping + property string variant: "default" // default, primary, success, warning, error + property string size: "md" // sm, md, lg + + // MD3 tonal surface container colors readonly property color _bgColor: { switch (variant) { - case "primary": return Theme.primaryContainer - case "success": return Theme.successContainer - case "warning": return Theme.warningContainer - case "error": return Theme.errorContainer - default: return Theme.surfaceVariant + case "primary": return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + case "success": return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.12) + case "warning": return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12) + case "error": return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) + default: return Theme.surfaceVariant } } - - readonly property color _textColor: { + + // MD3 on-container text color + readonly property color _accentColor: { switch (variant) { case "primary": return Theme.primary case "success": return Theme.success case "warning": return Theme.warning - case "error": return Theme.error - default: return Theme.onSurface + case "error": return Theme.error + default: return Theme.text } } - + + readonly property color _labelColor: { + switch (variant) { + case "default": return Theme.textSecondary + default: return Qt.darker(_accentColor, 1.1) + } + } + // Size config readonly property var _sizes: ({ - sm: { padding: StyleVariables.spacingSm, valueSize: StyleVariables.fontSizeLg, labelSize: StyleVariables.fontSizeXs }, - md: { padding: StyleVariables.spacingMd, valueSize: StyleVariables.fontSize2xl, labelSize: StyleVariables.fontSizeSm }, - lg: { padding: StyleVariables.spacingLg, valueSize: 32, labelSize: StyleVariables.fontSizeMd } + sm: { padding: 12, valueSize: 20, labelSize: 11, iconSize: 20, spacing: 2 }, + md: { padding: 16, valueSize: 28, labelSize: 13, iconSize: 28, spacing: 4 }, + lg: { padding: 20, valueSize: 36, labelSize: 15, iconSize: 36, spacing: 6 } }) - + readonly property var _config: _sizes[size] || _sizes.md - + color: _bgColor - radius: StyleVariables.radiusMd - + radius: 12 // MD3 medium container radius + implicitWidth: contentRow.implicitWidth + _config.padding * 2 implicitHeight: contentRow.implicitHeight + _config.padding * 2 - + RowLayout { id: contentRow anchors.centerIn: parent spacing: StyleVariables.spacingSm - + // Icon Text { text: root.icon - font.pixelSize: root._config.valueSize + font.pixelSize: root._config.iconSize + color: root._accentColor visible: root.icon !== "" + Layout.alignment: Qt.AlignVCenter } - + ColumnLayout { - spacing: 2 - - // Value + spacing: root._config.spacing + + // Value - large prominent text Text { text: root.value - color: root._textColor + color: root._accentColor font.pixelSize: root._config.valueSize font.weight: Font.Bold + font.letterSpacing: -0.5 } - - // Label + + // Label - smaller secondary text Text { text: root.label - color: Theme.onSurfaceVariant + color: root._labelColor font.pixelSize: root._config.labelSize + font.weight: Font.Medium visible: root.label !== "" } } diff --git a/qml/components/data-display/CStatusBadge.qml b/qml/components/data-display/CStatusBadge.qml index f94510098..cd712ae87 100644 --- a/qml/components/data-display/CStatusBadge.qml +++ b/qml/components/data-display/CStatusBadge.qml @@ -1,63 +1,87 @@ import QtQuick +import QmlComponents 1.0 +/** + * CStatusBadge.qml - Material Design 3 Status Pill + * + * Status indicator pill with tonal background matching the status color. + * Statuses: completed/success, running/info, queued/warning, failed/error, unknown/neutral + */ Rectangle { id: badge - - property string status: "unknown" // completed, running, queued, failed, unknown + + property string status: "unknown" // completed, running, queued, failed, unknown, success, warning, error, info property string text: status property bool showDot: true property var themeColors: ({}) - - // Internal colors with fallbacks - readonly property var colors: ({ - success: themeColors.success || "#22c55e", - info: themeColors.info || "#3b82f6", - warning: themeColors.warning || "#f59e0b", - error: themeColors.error || "#ef4444", - neutral: themeColors.mid || "#333333" - }) - - implicitHeight: 22 - implicitWidth: badgeRow.implicitWidth + 12 - radius: 4 - - color: { - switch(status) { - case "completed": return colors.success - case "running": return colors.info - case "queued": return colors.warning - case "failed": return colors.error - default: return colors.neutral + + // Resolve status to a semantic category + readonly property string _semantic: { + switch (status) { + case "completed": + case "success": return "success" + case "running": + case "info": return "info" + case "queued": + case "warning": return "warning" + case "failed": + case "error": return "error" + default: return "neutral" } } - + + // MD3 status colors with theme fallbacks + readonly property color _statusColor: { + switch (_semantic) { + case "success": return themeColors.success || Theme.success + case "info": return themeColors.info || Theme.info + case "warning": return themeColors.warning || Theme.warning + case "error": return themeColors.error || Theme.error + default: return themeColors.mid || Theme.textSecondary + } + } + + // MD3 tonal background: 12% opacity of status color + readonly property color _bgColor: Qt.rgba(_statusColor.r, _statusColor.g, _statusColor.b, 0.12) + + // MD3 on-container text: full status color + readonly property color _textColor: _statusColor + + implicitHeight: 24 + implicitWidth: badgeRow.implicitWidth + 20 + radius: 12 // Full pill for status badges + + color: _bgColor + Row { id: badgeRow anchors.centerIn: parent spacing: 6 - - // Animated dot for running status + + // Status dot indicator Rectangle { anchors.verticalCenter: parent.verticalCenter width: 6 height: 6 radius: 3 - color: "#ffffff" - visible: badge.showDot && badge.status === "running" - + color: badge._textColor + visible: badge.showDot + + // Pulse animation for running/active status SequentialAnimation on opacity { - running: badge.status === "running" + running: badge._semantic === "info" && badge.showDot loops: Animation.Infinite - NumberAnimation { to: 0.3; duration: 500 } - NumberAnimation { to: 1.0; duration: 500 } + NumberAnimation { to: 0.3; duration: 600; easing.type: Easing.InOutSine } + NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine } } } - + Text { text: badge.text - font.pixelSize: 11 + font.pixelSize: 12 font.weight: Font.Medium - color: "#ffffff" + font.letterSpacing: 0.2 + color: badge._textColor textFormat: Text.PlainText } } diff --git a/qml/components/feedback/CErrorState.qml b/qml/components/feedback/CErrorState.qml index 95f708b44..595ffafb1 100644 --- a/qml/components/feedback/CErrorState.qml +++ b/qml/components/feedback/CErrorState.qml @@ -3,70 +3,127 @@ import QtQuick.Layouts import QmlComponents 1.0 /** - * CErrorState.qml - Error state display (mirrors _error-state.scss) - * Shows error message with optional retry action + * CErrorState.qml - Material Design 3 Error State + * + * MD3 spec: Centered layout with tonal surface container, large icon, + * headline title, body description, and filled action button. + * + * Usage: + * CErrorState { + * title: "Connection failed" + * message: "Check your internet and try again." + * onRetry: reloadData() + * } */ Rectangle { id: root - + + // Public properties property string title: "Something went wrong" property string message: "" - property string icon: "⚠️" + property string icon: "\u26A0" // Warning symbol (Unicode) + property string size: "md" // sm, md, lg property bool showRetry: true property string retryText: "Try Again" - + signal retry() - - color: Theme.errorContainer - radius: StyleVariables.radiusMd - + + // MD3 tonal surface container + color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.7) + radius: 16 + border.width: 1 + border.color: Qt.rgba(Theme.border.r, Theme.border.g, Theme.border.b, 0.3) + implicitWidth: parent ? parent.width : 400 - implicitHeight: contentCol.implicitHeight + StyleVariables.spacingLg * 2 - + implicitHeight: contentCol.implicitHeight + Theme.spacingXl * 2 + ColumnLayout { id: contentCol anchors.centerIn: parent - width: parent.width - StyleVariables.spacingLg * 2 - spacing: StyleVariables.spacingMd - - // Icon - Text { + width: Math.min(parent.width - Theme.spacingXl * 2, 360) + spacing: Theme.spacingMd + + // MD3 large icon (48px) + Rectangle { Layout.alignment: Qt.AlignHCenter - text: root.icon - font.pixelSize: 48 + width: 72 + height: 72 + radius: 36 + color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) + + Text { + anchors.centerIn: parent + text: root.icon + font.pixelSize: 48 + color: Theme.error + } } - - // Title + + // MD3 headline: h5 weight Text { Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true text: root.title - color: Theme.error - font.pixelSize: StyleVariables.fontSizeLg - font.weight: Font.DemiBold + color: Theme.text + font.pixelSize: Theme.fontSizeH5 + font.weight: Font.Medium + font.family: Theme.fontFamily horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } - - // Message + + // MD3 body: body2, textSecondary Text { Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true text: root.message - color: Theme.onErrorContainer - font.pixelSize: StyleVariables.fontSizeSm + color: Theme.textSecondary + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontFamily horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap + lineHeight: 1.5 visible: root.message !== "" } - - // Retry button - CButton { - Layout.alignment: Qt.AlignHCenter - text: root.retryText - variant: "outlined" + + // Spacer before button + Item { + Layout.fillWidth: true + height: Theme.spacingXs visible: root.showRetry - onClicked: root.retry() + } + + // MD3 filled button + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: buttonLabel.implicitWidth + Theme.spacingLg * 2 + height: 40 + radius: 20 + color: buttonArea.containsMouse ? Qt.lighter(Theme.primary, 1.15) : Theme.primary + visible: root.showRetry + + Text { + id: buttonLabel + anchors.centerIn: parent + text: root.retryText + color: Theme.primaryContrastText + font.pixelSize: Theme.fontSizeSm + font.weight: Font.Medium + font.family: Theme.fontFamily + font.letterSpacing: 0.5 + } + + MouseArea { + id: buttonArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.retry() + } + + Behavior on color { + ColorAnimation { duration: Theme.transitionShortest } + } } } } diff --git a/qml/components/feedback/CProgress.qml b/qml/components/feedback/CProgress.qml index 1f922e097..5f7a02275 100644 --- a/qml/components/feedback/CProgress.qml +++ b/qml/components/feedback/CProgress.qml @@ -2,183 +2,108 @@ import QtQuick import QmlComponents 1.0 /** - * CProgress.qml - Progress indicator (mirrors _progress.scss) - * Linear or circular progress indicator - * + * CProgress.qml - Material Design 3 Linear Progress Indicator + * + * MD3 spec: 4px track with rounded ends, primary indicator, indeterminate sliding animation + * * Usage: - * CProgress { value: 0.5 } // 50% linear - * CProgress { variant: "circular"; value: 0.75 } - * CProgress { indeterminate: true } // Animated indeterminate + * CProgress { value: 0.5 } // 50% determinate + * CProgress { indeterminate: true } // Animated indeterminate + * CProgress { value: 0.75; label: "75%" } // With label */ Item { id: root - + // Public properties - property real value: 0 // 0.0 to 1.0 - property string variant: "linear" // linear, circular + property real value: 0 // 0.0 to 1.0 property bool indeterminate: false - property string color: "" // Custom color, uses primary if empty - property string trackColor: "" // Custom track color - property int thickness: 4 // Line thickness - property string size: "md" // sm, md, lg (for circular) - property string label: "" // Optional label (for linear) - - // Computed colors - readonly property color _progressColor: color || Theme.primary - readonly property color _trackColor: trackColor || Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.12) - - // Size based on variant - implicitWidth: variant === "circular" ? _circularSize : 200 - implicitHeight: variant === "circular" ? _circularSize : (label ? thickness + StyleVariables.fontSizeSm + StyleVariables.spacingXs : thickness) - - readonly property int _circularSize: { + property string color: "" // Custom indicator color, uses Theme.primary if empty + property string trackColor: "" // Custom track color + property string size: "md" // sm, md, lg (track thickness) + property string label: "" // Optional label below track + + // Computed colors (MD3: primary for indicator, surfaceContainerHighest for track) + readonly property color _indicatorColor: color || Theme.primary + readonly property color _trackColor: trackColor || Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.38) + + // MD3 track height: 4px standard + readonly property int _trackHeight: { switch (size) { - case "sm": return 24 - case "lg": return 56 - default: return 40 + case "sm": return 2 + case "lg": return 6 + default: return 4 } } - - // Linear progress - Item { - visible: root.variant === "linear" - anchors.fill: parent - - // Track + + implicitWidth: 200 + implicitHeight: label ? _trackHeight + Theme.fontSizeXs + Theme.spacingXs : _trackHeight + + // Track background (MD3: rounded, surfaceContainerHighest) + Rectangle { + id: track + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root._trackHeight + radius: root._trackHeight / 2 + color: root._trackColor + clip: true + + // Determinate indicator Rectangle { - id: track - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: root.thickness - radius: root.thickness / 2 - color: root._trackColor - } - - // Progress bar - Rectangle { - id: progressBar + id: determinateBar + visible: !root.indeterminate anchors.left: parent.left anchors.top: parent.top - height: root.thickness - width: root.indeterminate ? parent.width * 0.3 : parent.width * root.value - radius: root.thickness / 2 - color: root._progressColor - + height: parent.height + width: parent.width * Math.max(0, Math.min(1, root.value)) + radius: root._trackHeight / 2 + color: root._indicatorColor + Behavior on width { - enabled: !root.indeterminate - NumberAnimation { duration: StyleVariables.transitionNormal } + NumberAnimation { + duration: Theme.transitionStandard + easing.type: Easing.OutCubic + } } } - - // Indeterminate animation - SequentialAnimation on progressBar.x { - running: root.indeterminate && root.variant === "linear" - loops: Animation.Infinite - - NumberAnimation { - from: -track.width * 0.3 - to: track.width - duration: 1500 - easing.type: Easing.InOutQuad + + // Indeterminate indicator (MD3: sliding bar that traverses the track) + Rectangle { + id: indeterminateBar + visible: root.indeterminate + anchors.top: parent.top + height: parent.height + width: parent.width * 0.4 + radius: root._trackHeight / 2 + color: root._indicatorColor + + SequentialAnimation { + running: root.indeterminate && root.visible + loops: Animation.Infinite + + // Slide right, accelerating + NumberAnimation { + target: indeterminateBar + property: "x" + from: -indeterminateBar.width + to: track.width + duration: 1500 + easing.type: Easing.InOutQuad + } } } - - // Label - Text { - visible: root.label - anchors.top: track.bottom - anchors.topMargin: StyleVariables.spacingXs - anchors.horizontalCenter: parent.horizontalCenter - text: root.label - font.pixelSize: StyleVariables.fontSizeXs - color: Theme.textSecondary - } } - - // Circular progress - Item { - visible: root.variant === "circular" - anchors.fill: parent - - // Track circle - Canvas { - id: circularTrack - anchors.fill: parent - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - ctx.strokeStyle = root._trackColor - ctx.lineWidth = root.thickness - ctx.lineCap = "round" - - var centerX = width / 2 - var centerY = height / 2 - var radius = (Math.min(width, height) - root.thickness) / 2 - - ctx.beginPath() - ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI) - ctx.stroke() - } - } - - // Progress arc - Canvas { - id: circularProgress - anchors.fill: parent - - property real animatedValue: root.indeterminate ? 0.25 : root.value - property real rotation: 0 - - Behavior on animatedValue { - enabled: !root.indeterminate - NumberAnimation { duration: StyleVariables.transitionNormal } - } - - // Indeterminate rotation - RotationAnimation on rotation { - running: root.indeterminate - from: 0 - to: 360 - duration: 1400 - loops: Animation.Infinite - } - - transform: Rotation { - origin.x: circularProgress.width / 2 - origin.y: circularProgress.height / 2 - angle: circularProgress.rotation - 90 - } - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - ctx.strokeStyle = root._progressColor - ctx.lineWidth = root.thickness - ctx.lineCap = "round" - - var centerX = width / 2 - var centerY = height / 2 - var radius = (Math.min(width, height) - root.thickness) / 2 - - ctx.beginPath() - ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI * animatedValue) - ctx.stroke() - } - - onAnimatedValueChanged: requestPaint() - onRotationChanged: requestPaint() - } - - // Center label (percentage) - Text { - visible: !root.indeterminate && root.size !== "sm" - anchors.centerIn: parent - text: Math.round(root.value * 100) + "%" - font.pixelSize: root._circularSize / 4 - font.weight: Font.Medium - color: Theme.text - } + + // Optional label + Text { + visible: root.label !== "" + anchors.top: track.bottom + anchors.topMargin: Theme.spacingXs + anchors.horizontalCenter: parent.horizontalCenter + text: root.label + font.pixelSize: Theme.fontSizeXs + font.family: Theme.fontFamily + color: Theme.textSecondary } } diff --git a/qml/components/feedback/CSpinner.qml b/qml/components/feedback/CSpinner.qml index e3bca7036..35d7325a4 100644 --- a/qml/components/feedback/CSpinner.qml +++ b/qml/components/feedback/CSpinner.qml @@ -2,68 +2,101 @@ import QtQuick import QmlComponents 1.0 /** - * CSpinner.qml - Loading spinner (mirrors _spinner.scss) - * Simple rotating loading indicator - * + * CSpinner.qml - Material Design 3 Circular Progress Indicator + * + * MD3 spec: Canvas-based arc with rotation + arc sweep animation. + * Arc sweeps between 10-300 degrees while the whole spinner rotates at 1.5s/rev. + * * Usage: * CSpinner {} - * CSpinner { size: "lg"; color: Theme.success } + * CSpinner { size: "lg" } + * CSpinner { size: "sm"; color: Theme.success } */ Item { id: root - + // Public properties - property string size: "md" // sm, md, lg + property string size: "md" // sm (24px), md (40px), lg (56px) property color color: Theme.primary - property int strokeWidth: size === "sm" ? 2 : (size === "lg" ? 4 : 3) - - // Size - implicitWidth: _size - implicitHeight: _size - - readonly property int _size: { + property int strokeWidth: 4 + + // Size mapping (MD3 defaults) + readonly property int _diameter: { switch (size) { - case "sm": return 20 - case "lg": return 48 - default: return 32 + case "sm": return 24 + case "lg": return 56 + default: return 40 } } - - // Spinning animation + + implicitWidth: _diameter + implicitHeight: _diameter + + // Rotation animation: 1.5s per full revolution + NumberAnimation on rotation { + from: 0 + to: 360 + duration: 1500 + loops: Animation.Infinite + running: root.visible + } + Canvas { id: canvas anchors.fill: parent - - property real angle: 0 - - RotationAnimation on angle { + + // Arc sweep: oscillates between 10 and 300 degrees + property real sweepAngle: 10 + property real sweepOffset: 0 + + SequentialAnimation on sweepAngle { + running: root.visible + loops: Animation.Infinite + + NumberAnimation { + from: 10 + to: 300 + duration: 750 + easing.type: Easing.InOutCubic + } + NumberAnimation { + from: 300 + to: 10 + duration: 750 + easing.type: Easing.InOutCubic + } + } + + // Offset rotates so the shrinking arc doesn't snap back + NumberAnimation on sweepOffset { from: 0 - to: 360 - duration: 1000 + to: 720 + duration: 3000 loops: Animation.Infinite running: root.visible } - - onAngleChanged: requestPaint() - + + onSweepAngleChanged: requestPaint() + onSweepOffsetChanged: requestPaint() + onPaint: { var ctx = getContext("2d") ctx.reset() - + var centerX = width / 2 var centerY = height / 2 var radius = (Math.min(width, height) - root.strokeWidth) / 2 - - // Draw arc + ctx.strokeStyle = root.color ctx.lineWidth = root.strokeWidth ctx.lineCap = "round" - - var startAngle = (angle - 90) * Math.PI / 180 - var endAngle = startAngle + 1.5 * Math.PI - + + // Convert degrees to radians + var startRad = (sweepOffset - 90) * Math.PI / 180 + var endRad = startRad + sweepAngle * Math.PI / 180 + ctx.beginPath() - ctx.arc(centerX, centerY, radius, startAngle, endAngle) + ctx.arc(centerX, centerY, radius, startRad, endRad) ctx.stroke() } } diff --git a/qml/components/form/CCheckbox.qml b/qml/components/form/CCheckbox.qml index 7209fd0a7..60dce4f22 100644 --- a/qml/components/form/CCheckbox.qml +++ b/qml/components/form/CCheckbox.qml @@ -3,48 +3,122 @@ import QtQuick.Controls import QmlComponents 1.0 /** - * CCheckbox.qml - styled checkbox wrapper + * CCheckbox.qml - Material Design 3 styled checkbox + * + * MD3 spec: 18px box, 2px border, slight rounding (radius 2), + * animated fill on check, white checkmark via Canvas. */ Rectangle { id: root + property bool checked: false property bool indeterminate: false property alias text: label.text + property bool enabled: true + signal toggled(bool checked) - width: 120 - height: 28 + width: row.implicitWidth + height: 40 color: "transparent" Row { - anchors.fill: parent + id: row spacing: StyleVariables.spacingSm anchors.verticalCenter: parent.verticalCenter - Rectangle { - id: box - width: 18 - height: 18 - radius: 3 - color: checked ? Theme.primary : Theme.surfaceVariant - border.color: Theme.divider - border.width: 1 + // Ripple touch target (40x40 centered on 18px box) + Item { + width: 40 + height: 40 - Text { + // Hover/press ripple circle + Rectangle { + id: ripple anchors.centerIn: parent - text: indeterminate ? "-" : (checked ? "✓" : "") - color: Theme.onPrimary - font.pixelSize: 12 + width: 40; height: 40 + radius: 20 + color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08) + opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } } - MouseArea { anchors.fill: parent; onClicked: { root.checked = !root.checked; root.toggled(root.checked) } } + // The checkbox box + Rectangle { + id: box + anchors.centerIn: parent + width: 18; height: 18 + radius: 2 + color: root.checked || root.indeterminate ? Theme.primary : "transparent" + border.color: root.checked || root.indeterminate ? Theme.primary : Theme.border + border.width: 2 + opacity: root.enabled ? 1.0 : 0.38 + + Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + + // Checkmark drawn via Canvas + Canvas { + id: checkCanvas + anchors.centerIn: parent + width: 12; height: 12 + visible: root.checked || root.indeterminate + opacity: visible ? 1 : 0 + + Behavior on opacity { NumberAnimation { duration: 150 } } + + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (root.indeterminate) { + // Horizontal dash for indeterminate + ctx.beginPath(); + ctx.moveTo(2, height / 2); + ctx.lineTo(width - 2, height / 2); + ctx.stroke(); + } else { + // Checkmark path + ctx.beginPath(); + ctx.moveTo(1, height * 0.5); + ctx.lineTo(width * 0.38, height - 2); + ctx.lineTo(width - 1, 2); + ctx.stroke(); + } + } + + Connections { + target: root + function onCheckedChanged() { checkCanvas.requestPaint() } + function onIndeterminateChanged() { checkCanvas.requestPaint() } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: { + root.checked = !root.checked; + root.toggled(root.checked); + } + } } Text { id: label text: "Checkbox" - color: Theme.onSurface + color: root.enabled ? Theme.text : Theme.textDisabled font.pixelSize: StyleVariables.fontSizeSm + font.family: Theme.fontFamily anchors.verticalCenter: parent.verticalCenter } } diff --git a/qml/components/form/CRadio.qml b/qml/components/form/CRadio.qml index 0c0ee06e2..5ca77de5b 100644 --- a/qml/components/form/CRadio.qml +++ b/qml/components/form/CRadio.qml @@ -2,41 +2,98 @@ import QtQuick import QmlComponents 1.0 /** - * CRadio.qml - radio control + * CRadio.qml - Material Design 3 styled radio button + * + * MD3 spec: 20px outer circle, 2px border, 10px inner dot + * with scale animation on selection. */ -Item { +Rectangle { id: root + property bool checked: false property alias text: label.text + property bool enabled: true + signal toggled(bool checked) - width: 160 - height: 28 + width: row.implicitWidth + height: 40 + color: "transparent" Row { - anchors.fill: parent + id: row spacing: StyleVariables.spacingSm anchors.verticalCenter: parent.verticalCenter - Canvas { - id: circle - width: 18; height: 18 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0,0,width,height); - ctx.beginPath(); - ctx.arc(width/2, height/2, 8, 0, 2*Math.PI); - ctx.fillStyle = root.checked ? Theme.primary : Theme.surfaceVariant; - ctx.fill(); - ctx.strokeStyle = Theme.divider; - ctx.stroke(); - if (root.checked) { - ctx.beginPath(); ctx.arc(width/2, height/2, 4, 0, 2*Math.PI); ctx.fillStyle = Theme.onPrimary; ctx.fill(); + // Ripple touch target + Item { + width: 40; height: 40 + + // Hover/press ripple + Rectangle { + id: ripple + anchors.centerIn: parent + width: 40; height: 40 + radius: 20 + color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08) + opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + // Outer circle + Rectangle { + id: outerCircle + anchors.centerIn: parent + width: 20; height: 20 + radius: 10 + color: "transparent" + border.width: 2 + border.color: root.checked ? Theme.primary : Theme.border + opacity: root.enabled ? 1.0 : 0.38 + + Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + + // Inner filled dot + Rectangle { + id: innerDot + anchors.centerIn: parent + width: 10; height: 10 + radius: 5 + color: Theme.primary + opacity: root.enabled ? 1.0 : 0.38 + scale: root.checked ? 1.0 : 0.0 + visible: scale > 0 + + Behavior on scale { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: { + root.checked = true; + root.toggled(true); } } - MouseArea { anchors.fill: parent; onClicked: { root.checked = true; root.toggled(true) } } } - Text { id: label; text: "Option"; color: Theme.onSurface; font.pixelSize: StyleVariables.fontSizeSm } + Text { + id: label + text: "Option" + color: root.enabled ? Theme.text : Theme.textDisabled + font.pixelSize: StyleVariables.fontSizeSm + font.family: Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } } } diff --git a/qml/components/form/CRating.qml b/qml/components/form/CRating.qml index 605578b01..d48754396 100644 --- a/qml/components/form/CRating.qml +++ b/qml/components/form/CRating.qml @@ -2,22 +2,70 @@ import QtQuick import QmlComponents 1.0 /** - * CRating.qml - simple star rating control + * CRating.qml - Material Design 3 styled star rating + * + * Interactive star rating with hover preview, unicode stars, + * and themed colors (primary for filled, border for empty). */ Row { id: root + property int value: 0 property int max: 5 property bool readOnly: false + property int hoverValue: -1 + property color filledColor: "#ffc107" // MD3 amber / override with Theme.primary if desired + property color emptyColor: Theme.border + + signal valueChanged(int newValue) + spacing: 4 Repeater { model: root.max - delegate: Text { - text: index < root.value ? "★" : "☆" - font.pixelSize: 18 - color: index < root.value ? Theme.primary : Theme.onSurfaceVariant - MouseArea { anchors.fill: parent; enabled: !root.readOnly; onClicked: root.value = index+1 } + + delegate: Item { + id: starItem + width: 32; height: 32 + + property bool isFilled: { + if (root.hoverValue >= 0) + return index < root.hoverValue; + return index < root.value; + } + + Text { + id: starText + anchors.centerIn: parent + text: starItem.isFilled ? "\u2605" : "\u2606" + font.pixelSize: 24 + color: starItem.isFilled ? root.filledColor : root.emptyColor + + Behavior on color { ColorAnimation { duration: 150 } } + + // Scale bump on hover + scale: starMouseArea.containsMouse ? 1.2 : 1.0 + Behavior on scale { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + } + + MouseArea { + id: starMouseArea + anchors.fill: parent + hoverEnabled: !root.readOnly + enabled: !root.readOnly + cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor + + onClicked: { + root.value = index + 1; + root.valueChanged(root.value); + } + onEntered: { + root.hoverValue = index + 1; + } + onExited: { + root.hoverValue = -1; + } + } } } } diff --git a/qml/components/form/CSelect.qml b/qml/components/form/CSelect.qml index fc8821897..80b0551c0 100644 --- a/qml/components/form/CSelect.qml +++ b/qml/components/form/CSelect.qml @@ -3,10 +3,193 @@ import QtQuick.Controls import QtQuick.Layouts /** - * CSelect.qml - simple select (ComboBox wrapper) + * CSelect.qml - Material Design 3 outlined select (ComboBox wrapper) + * + * Outlined variant matching CTextField styling with dropdown popup, + * surface background, elevation shadow, and arrow indicator. */ ComboBox { id: root + + property string label: "" + property string helper: "" + property string errorText: "" + property bool hasError: errorText.length > 0 + property string size: "md" // "sm", "md", "lg" + model: [] Layout.preferredWidth: 200 + implicitHeight: size === "sm" ? 40 : (size === "lg" ? 56 : 48) + + font.pixelSize: 14 + font.family: Theme.fontFamily + + // Content item (selected value display) + contentItem: Text { + leftPadding: 16 + rightPadding: 40 + verticalAlignment: Text.AlignVCenter + text: root.displayText + font: root.font + color: root.enabled ? Theme.text : Theme.textDisabled + elide: Text.ElideRight + } + + // Arrow indicator + indicator: Item { + width: 40 + height: root.height + anchors.right: parent.right + + Text { + anchors.centerIn: parent + text: root.popup.visible ? "\u25B2" : "\u25BC" + font.pixelSize: 10 + color: { + if (!root.enabled) return Theme.textDisabled + if (root.activeFocus) return Theme.primary + return Theme.textSecondary + } + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + } + } + + // Outlined background matching CTextField + background: Rectangle { + id: bg + radius: 8 + color: root.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent" + border.width: root.activeFocus ? 2 : 1 + border.color: { + if (!root.enabled) return Theme.actionDisabled + if (root.hasError) return Theme.error + if (root.activeFocus) return Theme.primary + if (bgHover.containsMouse) return Theme.text + return Theme.border + } + + Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } } + Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + MouseArea { + id: bgHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + + // Floating label + Text { + id: floatingLabel + text: root.label + visible: root.label.length > 0 + font.pixelSize: (root.activeFocus || root.currentIndex >= 0) ? 11 : 14 + font.family: Theme.fontFamily + font.weight: Font.Medium + color: { + if (!root.enabled) return Theme.textDisabled + if (root.hasError) return Theme.error + if (root.activeFocus) return Theme.primary + return Theme.textSecondary + } + + x: 16 + y: (root.activeFocus || root.currentIndex >= 0) ? 6 : (parent.height - height) / 2 + + Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + } + } + + // Dropdown popup + popup: Popup { + y: root.height + 4 + width: root.width + implicitHeight: contentItem.implicitHeight + 16 + padding: 8 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + + background: Rectangle { + radius: 8 + color: Theme.surface + border.width: 1 + border.color: Theme.border + + // Elevation shadow + layer.enabled: true + layer.effect: Item { + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: 10 + color: "transparent" + border.width: 0 + + Rectangle { + anchors.fill: parent + anchors.topMargin: 2 + radius: 10 + color: Qt.rgba(0, 0, 0, 0.12) + z: -1 + } + } + } + } + } + + // Delegate for each item in the dropdown + delegate: ItemDelegate { + id: itemDelegate + width: root.width - 16 + height: 40 + leftPadding: 16 + + contentItem: Text { + text: modelData !== undefined ? modelData : (model.display !== undefined ? model.display : "") + font.pixelSize: 14 + font.family: Theme.fontFamily + color: itemDelegate.highlighted ? Theme.primary : Theme.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 4 + color: { + if (itemDelegate.highlighted) return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + if (itemDelegate.hovered) return Theme.actionHover + return "transparent" + } + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + } + + highlighted: root.highlightedIndex === index + } + + // Helper / error text below the field + Text { + id: helperText + parent: root.parent + anchors.top: root.bottom + anchors.topMargin: 4 + anchors.left: root.left + anchors.leftMargin: 16 + text: root.hasError ? root.errorText : root.helper + font.pixelSize: 11 + font.family: Theme.fontFamily + color: root.hasError ? Theme.error : Theme.textSecondary + visible: text.length > 0 + } } diff --git a/qml/components/form/CSwitch.qml b/qml/components/form/CSwitch.qml index 7821a32b2..920387011 100644 --- a/qml/components/form/CSwitch.qml +++ b/qml/components/form/CSwitch.qml @@ -1,6 +1,107 @@ import QtQuick import QtQuick.Controls +import QmlComponents 1.0 -Switch { - id: sw +/** + * CSwitch.qml - Material Design 3 styled toggle switch + * + * MD3 spec: 52x32 pill track, 24px thumb (28px when pressed), + * animated slide and color transitions. + */ +Rectangle { + id: root + + property bool checked: false + property alias text: label.text + property bool enabled: true + + signal toggled(bool checked) + + width: row.implicitWidth + height: 40 + color: "transparent" + + Row { + id: row + spacing: StyleVariables.spacingSm + anchors.verticalCenter: parent.verticalCenter + + // Track + thumb container + Item { + width: 52; height: 32 + anchors.verticalCenter: parent.verticalCenter + + // Track + Rectangle { + id: track + anchors.fill: parent + radius: 16 + color: root.checked ? Theme.primary : Theme.surface + border.color: root.checked ? "transparent" : Theme.border + border.width: root.checked ? 0 : 2 + opacity: root.enabled ? 1.0 : 0.38 + + Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + + // Thumb + Rectangle { + id: thumb + property int thumbSize: mouseArea.pressed ? 28 : 24 + width: thumbSize; height: thumbSize + radius: thumbSize / 2 + color: root.checked ? "#ffffff" : Theme.border + y: (parent.height - height) / 2 + x: root.checked ? (parent.width - width - 4) : 4 + opacity: root.enabled ? 1.0 : 0.38 + + // Elevation shadow for thumb + layer.enabled: true + layer.effect: null + + Behavior on x { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + + // Hover state circle around thumb + Rectangle { + id: hoverIndicator + width: 40; height: 40 + radius: 20 + color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08) + opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0 + x: thumb.x + (thumb.width / 2) - (width / 2) + y: thumb.y + (thumb.height / 2) - (height / 2) + + Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on x { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: { + root.checked = !root.checked; + root.toggled(root.checked); + } + } + } + + Text { + id: label + text: "" + color: root.enabled ? Theme.text : Theme.textDisabled + font.pixelSize: StyleVariables.fontSizeSm + font.family: Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + visible: text.length > 0 + } + } } diff --git a/qml/components/form/CTextField.qml b/qml/components/form/CTextField.qml index 3370877bb..2bdf76498 100644 --- a/qml/components/form/CTextField.qml +++ b/qml/components/form/CTextField.qml @@ -2,9 +2,15 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +/** + * CTextField.qml - Material Design 3 outlined text field + * + * Outlined variant with floating label, prefix/suffix icons, + * error state, and clearable support. + */ TextField { id: control - + property string label: "" property string helper: "" property string errorText: "" @@ -12,53 +18,104 @@ TextField { property string prefixIcon: "" property string suffixIcon: "" property bool clearable: false - + property string size: "md" // "sm", "md", "lg" + signal suffixClicked() - - implicitHeight: 40 - leftPadding: prefixIcon ? 36 : 12 - rightPadding: (clearable && text.length > 0) || suffixIcon ? 36 : 12 - + + implicitHeight: size === "sm" ? 40 : (size === "lg" ? 56 : 48) + leftPadding: prefixIcon ? 40 : 16 + rightPadding: (clearable && text.length > 0) || suffixIcon ? 40 : 16 + topPadding: label && (activeFocus || text.length > 0) ? 18 : 0 + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: 14 - color: "#ffffff" - placeholderTextColor: "#666666" - selectionColor: "#4dabf7" - selectedTextColor: "#ffffff" - + font.family: Theme.fontFamily + color: enabled ? Theme.text : Theme.textDisabled + placeholderTextColor: Theme.textSecondary + selectionColor: Theme.primary + selectedTextColor: Theme.primaryContrastText + background: Rectangle { + id: bg radius: 8 - color: "#1e1e1e" + color: control.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent" border.width: control.activeFocus ? 2 : 1 border.color: { - if (control.hasError) return "#f44336" - if (control.activeFocus) return "#4dabf7" - return "#3d3d3d" + if (!control.enabled) return Theme.actionDisabled + if (control.hasError) return Theme.error + if (control.activeFocus) return Theme.primary + if (bgMouseArea.containsMouse) return Theme.text + return Theme.border } - - Behavior on border.color { ColorAnimation { duration: 150 } } - Behavior on border.width { NumberAnimation { duration: 150 } } - + + Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } } + Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + // Hover detection for the background area + MouseArea { + id: bgMouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + } + + // Floating label + Text { + id: floatingLabel + text: control.label + visible: control.label.length > 0 + font.pixelSize: (control.activeFocus || control.text.length > 0) ? 11 : 14 + font.family: Theme.fontFamily + font.weight: Font.Medium + color: { + if (!control.enabled) return Theme.textDisabled + if (control.hasError) return Theme.error + if (control.activeFocus) return Theme.primary + return Theme.textSecondary + } + + x: control.prefixIcon ? 40 : 16 + y: (control.activeFocus || control.text.length > 0) ? 6 : (parent.height - height) / 2 + + Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + } + // Prefix icon Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: control.prefixIcon - font.pixelSize: 16 - color: "#888888" - visible: control.prefixIcon + font.pixelSize: 18 + color: { + if (!control.enabled) return Theme.textDisabled + if (control.activeFocus) return Theme.primary + return Theme.textSecondary + } + visible: control.prefixIcon.length > 0 + + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } } - - // Suffix/clear button + + // Suffix / clear button Text { + id: suffixText anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: control.clearable && control.text.length > 0 ? "✕" : control.suffixIcon - font.pixelSize: 14 - color: clearMouseArea.containsMouse ? "#ffffff" : "#888888" - visible: (control.clearable && control.text.length > 0) || control.suffixIcon - + text: control.clearable && control.text.length > 0 ? "\u2715" : control.suffixIcon + font.pixelSize: 16 + color: { + if (!control.enabled) return Theme.textDisabled + if (clearMouseArea.containsMouse) return Theme.text + return Theme.textSecondary + } + visible: (control.clearable && control.text.length > 0) || control.suffixIcon.length > 0 + MouseArea { id: clearMouseArea anchors.fill: parent @@ -75,4 +132,19 @@ TextField { } } } + + // Helper / error text below the field + Text { + id: helperText + parent: control.parent + anchors.top: control.bottom + anchors.topMargin: 4 + anchors.left: control.left + anchors.leftMargin: 16 + text: control.hasError ? control.errorText : control.helper + font.pixelSize: 11 + font.family: Theme.fontFamily + color: control.hasError ? Theme.error : Theme.textSecondary + visible: text.length > 0 + } } diff --git a/qml/components/form/CTextarea.qml b/qml/components/form/CTextarea.qml index 6db9d6847..a749f273a 100644 --- a/qml/components/form/CTextarea.qml +++ b/qml/components/form/CTextarea.qml @@ -1,6 +1,121 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts -TextArea { - wrapMode: Text.WordWrap +/** + * CTextarea.qml - Material Design 3 outlined multi-line text area + * + * Same outline treatment as CTextField but supports multiple lines. + * Min height 100px, grows with content. + */ +ScrollView { + id: root + + property alias text: textArea.text + property alias placeholderText: textArea.placeholderText + property alias readOnly: textArea.readOnly + property alias wrapMode: textArea.wrapMode + property alias font: textArea.font + property alias textDocument: textArea.textDocument + property string label: "" + property string helper: "" + property string errorText: "" + property bool hasError: errorText.length > 0 + property string size: "md" // "sm", "md", "lg" + property alias activeFocus: textArea.activeFocus + property bool enabled: true + property int minHeight: 100 + property int maxHeight: 400 + + signal textChanged() + + implicitWidth: 200 + implicitHeight: Math.max(minHeight, Math.min(textArea.implicitHeight + 32, maxHeight)) + clip: true + + ScrollBar.vertical.policy: textArea.implicitHeight + 32 > root.maxHeight ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + TextArea { + id: textArea + wrapMode: Text.WordWrap + font.pixelSize: 14 + font.family: Theme.fontFamily + color: root.enabled ? Theme.text : Theme.textDisabled + placeholderTextColor: Theme.textSecondary + selectionColor: Theme.primary + selectedTextColor: Theme.primaryContrastText + enabled: root.enabled + + leftPadding: 16 + rightPadding: 16 + topPadding: root.label.length > 0 ? 24 : 14 + bottomPadding: 14 + + onTextChanged: root.textChanged() + + background: Rectangle { + id: bg + radius: 8 + color: textArea.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent" + border.width: textArea.activeFocus ? 2 : 1 + border.color: { + if (!root.enabled) return Theme.actionDisabled + if (root.hasError) return Theme.error + if (textArea.activeFocus) return Theme.primary + if (bgHover.containsMouse) return Theme.text + return Theme.border + } + + Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } } + Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + + MouseArea { + id: bgHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + } + + // Floating label + Text { + id: floatingLabel + text: root.label + visible: root.label.length > 0 + font.pixelSize: (textArea.activeFocus || textArea.text.length > 0) ? 11 : 14 + font.family: Theme.fontFamily + font.weight: Font.Medium + color: { + if (!root.enabled) return Theme.textDisabled + if (root.hasError) return Theme.error + if (textArea.activeFocus) return Theme.primary + return Theme.textSecondary + } + + x: 16 + y: (textArea.activeFocus || textArea.text.length > 0) ? 6 : 14 + + Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Theme.transitionShortest } } + } + } + } + + // Helper / error text below the field + Text { + id: helperText + parent: root.parent + anchors.top: root.bottom + anchors.topMargin: 4 + anchors.left: root.left + anchors.leftMargin: 16 + text: root.hasError ? root.errorText : root.helper + font.pixelSize: 11 + font.family: Theme.fontFamily + color: root.hasError ? Theme.error : Theme.textSecondary + visible: text.length > 0 + } } diff --git a/qml/components/surfaces/CAccordionItem.qml b/qml/components/surfaces/CAccordionItem.qml index 3ccce6b5d..bef329878 100644 --- a/qml/components/surfaces/CAccordionItem.qml +++ b/qml/components/surfaces/CAccordionItem.qml @@ -3,54 +3,71 @@ import QtQuick.Layouts import QmlComponents 1.0 /** - * CAccordionItem.qml - Single accordion item - * Used inside CAccordion + * CAccordionItem.qml - Material Design 3 expandable accordion item + * + * Rounded 12 px container with smooth height animation. + * Used inside CAccordion; pairs with CCard visual language. */ Rectangle { id: root objectName: "CAccordionItem" - - // Public properties + + // ── Public properties ─────────────────────────────────────────── property string title: "" property string icon: "" property string subtitle: "" property bool expanded: false property bool disabled: false - + // Internal - set by parent accordion property int index: 0 property var accordion: null - + // Content slot default property alias content: contentColumn.data - - // Size + + // ── MD3 surface tints ─────────────────────────────────────────── + readonly property bool isDark: Theme.mode === "dark" + readonly property color surfaceContainer: + isDark ? Qt.rgba(1, 1, 1, 0.05) : Qt.rgba(0.31, 0.31, 0.44, 0.06) + + // ── Layout ────────────────────────────────────────────────────── Layout.fillWidth: true - implicitHeight: headerRect.height + (expanded ? contentLoader.height : 0) - - // Appearance - color: "transparent" - - // Animation + radius: 12 + clip: true + + implicitHeight: headerRect.height + (expanded ? contentWrapper.height : 0) + implicitWidth: 300 + + color: surfaceContainer + Behavior on implicitHeight { - NumberAnimation { duration: StyleVariables.transitionNormal; easing.type: Easing.OutCubic } + NumberAnimation { + duration: StyleVariables.transitionNormal + easing.type: Easing.OutCubic + } } - - // Header + + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } + + // ── Header ────────────────────────────────────────────────────── Rectangle { id: headerRect width: parent.width - height: 48 - color: headerMouse.containsMouse && !root.disabled ? StyleMixins.hoverBg(Theme.mode === "dark") : "transparent" - + height: 52 + radius: root.radius + color: headerMouse.containsMouse && !root.disabled + ? StyleMixins.hoverBg(root.isDark) + : "transparent" + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } - + MouseArea { id: headerMouse anchors.fill: parent hoverEnabled: !root.disabled cursorShape: root.disabled ? Qt.ForbiddenCursor : Qt.PointingHandCursor - + onClicked: { if (!root.disabled) { root.expanded = !root.expanded @@ -60,26 +77,26 @@ Rectangle { } } } - + RowLayout { anchors.fill: parent - anchors.leftMargin: StyleVariables.spacingMd - anchors.rightMargin: StyleVariables.spacingMd + anchors.leftMargin: 16 + anchors.rightMargin: 16 spacing: StyleVariables.spacingSm - + // Icon Text { - visible: root.icon + visible: root.icon !== "" text: root.icon font.pixelSize: StyleVariables.fontSizeLg color: root.disabled ? Theme.textDisabled : Theme.text } - + // Title and subtitle ColumnLayout { Layout.fillWidth: true spacing: 2 - + Text { text: root.title font.pixelSize: StyleVariables.fontSizeSm @@ -88,9 +105,9 @@ Rectangle { elide: Text.ElideRight Layout.fillWidth: true } - + Text { - visible: root.subtitle + visible: root.subtitle !== "" text: root.subtitle font.pixelSize: StyleVariables.fontSizeXs color: Theme.textSecondary @@ -98,58 +115,56 @@ Rectangle { Layout.fillWidth: true } } - - // Expand indicator + + // Expand chevron Text { - text: "▼" + text: "\u25BC" font.pixelSize: 10 color: root.disabled ? Theme.textDisabled : Theme.textSecondary rotation: root.expanded ? 180 : 0 - + Behavior on rotation { - NumberAnimation { duration: StyleVariables.transitionNormal } + NumberAnimation { + duration: StyleVariables.transitionNormal + easing.type: Easing.OutCubic + } } } } - - // Bottom border + + // Divider between header and content Rectangle { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - height: 1 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + height: root.expanded ? 1 : 0 color: Theme.divider + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + NumberAnimation { duration: StyleVariables.transitionFast } + } } } - - // Content - Loader { - id: contentLoader + + // ── Content area ──────────────────────────────────────────────── + Item { + id: contentWrapper anchors.top: headerRect.bottom width: parent.width - active: root.expanded + height: root.expanded ? contentColumn.implicitHeight + 32 : 0 visible: root.expanded - - sourceComponent: Rectangle { - width: contentLoader.width - height: contentColumn.implicitHeight + StyleVariables.spacingMd * 2 - color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.3) - - ColumnLayout { - id: contentColumn - anchors.fill: parent - anchors.margins: StyleVariables.spacingMd - spacing: StyleVariables.spacingSm - } - - // Bottom border - Rectangle { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: 1 - color: Theme.divider - } + clip: true + + ColumnLayout { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 + spacing: StyleVariables.spacingSm } } } diff --git a/qml/components/surfaces/CPaper.qml b/qml/components/surfaces/CPaper.qml index ec2e007f7..2e80f433a 100644 --- a/qml/components/surfaces/CPaper.qml +++ b/qml/components/surfaces/CPaper.qml @@ -1,8 +1,22 @@ import QtQuick import QtQuick.Controls +import QmlComponents 1.0 +/** + * CPaper.qml - Material Design 3 surface container + * + * A simple themed rectangle that provides a paper-like background. + * Serves as the base surface for dialogs, drawers, side-panels, etc. + */ Rectangle { - color: "white" - radius: 4 - border.color: "#ddd" + id: paper + + property int elevation: 0 + + color: Theme.paper + radius: 12 + border.width: 0 + border.color: "transparent" + + Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } } }