mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat(qml): MD3 rework batch 1 — 20 components rewritten
Core: CButton (pill variants), CFab (radius 16 tonal), CIconButton (circle state layers) Cards: CCard (filled/outlined/elevated, radius 12), CAccordionItem (smooth expand), CPaper Chips: CChip (8px radius, filter/assist/input), CBadge (6px dot/16px pill), CStatBadge, CStatusBadge Forms: CTextField (floating label, outlined), CSelect (styled popup), CTextarea (scrollable) Toggles: CCheckbox (Canvas checkmark), CSwitch (52x32 pill track), CRadio (scale dot), CRating (hover stars) Feedback: CProgress (4px track, indeterminate slide), CSpinner (Canvas arc sweep), CErrorState (tonal container) All components: theme-aware, dark/light mode, hover/press state layers, preserved public API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !== ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user