mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat(qml): MD3 rework batch 2 — 17 more components rewritten
Feedback: CAlert (tonal + accent bar), CDialog (radius 28, scale anim), CSnackbar (inverse surface, slide-in) Navigation: CTabBar (animated indicator pill), CListItem (state layers), CBreadcrumbs (full rewrite) Data: CAvatar (tonal primary), CDivider (theme-aware), CTable (hover rows, sort arrows, proper padding) Typography: CText (full MD3 type scale inline), CTitle (extends CText), CCodeBlock (radius 12), CCodeInline (radius 4) Forms: CFormGroup (focus/error states), CFormLabel (animated color), CLabel (control association), CAutocomplete (styled popup) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,56 +4,56 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CCodeBlock.qml - Code block display (mirrors _code-block.scss)
|
||||
* Syntax-highlighted code with optional copy button
|
||||
* CCodeBlock.qml - Material Design 3 code block
|
||||
* Displays code with optional line numbers, copy button, and horizontal scroll
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
|
||||
property string code: ""
|
||||
property string language: ""
|
||||
property bool showCopy: true
|
||||
property bool showLineNumbers: false
|
||||
property int maxHeight: 400
|
||||
|
||||
color: Theme.mode === "dark" ? Qt.rgba(0, 0, 0, 0.4) : Qt.rgba(0, 0, 0, 0.05)
|
||||
radius: StyleVariables.radiusSm
|
||||
|
||||
|
||||
color: Qt.lighter(Theme.surface, 1.15)
|
||||
radius: 12
|
||||
|
||||
implicitWidth: parent ? parent.width : 400
|
||||
implicitHeight: Math.min(contentCol.implicitHeight + StyleVariables.spacingMd * 2, maxHeight)
|
||||
|
||||
implicitHeight: Math.min(contentCol.implicitHeight + 32, maxHeight)
|
||||
|
||||
ColumnLayout {
|
||||
id: contentCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: StyleVariables.spacingMd
|
||||
spacing: StyleVariables.spacingSm
|
||||
|
||||
// Header with language and copy button
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
// Header with language label and copy button
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: root.language !== "" || root.showCopy
|
||||
|
||||
|
||||
Text {
|
||||
text: root.language
|
||||
color: Theme.onSurfaceVariant
|
||||
font.pixelSize: StyleVariables.fontSizeXs
|
||||
font.family: StyleVariables.fontMono
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.fontFamilyMono
|
||||
font.weight: Font.Medium
|
||||
visible: root.language !== ""
|
||||
}
|
||||
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
|
||||
CButton {
|
||||
text: "Copy"
|
||||
size: "sm"
|
||||
variant: "text"
|
||||
visible: root.showCopy
|
||||
onClicked: {
|
||||
// Copy to clipboard would need Python bridge
|
||||
text = "Copied!"
|
||||
copyTimer.start()
|
||||
}
|
||||
|
||||
|
||||
Timer {
|
||||
id: copyTimer
|
||||
interval: 2000
|
||||
@@ -61,49 +61,50 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Code content
|
||||
|
||||
// Scrollable code content
|
||||
Flickable {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentWidth: codeText.implicitWidth
|
||||
contentHeight: codeText.implicitHeight
|
||||
contentWidth: codeRow.implicitWidth
|
||||
contentHeight: codeRow.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
|
||||
RowLayout {
|
||||
spacing: StyleVariables.spacingSm
|
||||
|
||||
// Line numbers
|
||||
id: codeRow
|
||||
spacing: 12
|
||||
|
||||
// Line numbers column
|
||||
Column {
|
||||
visible: root.showLineNumbers
|
||||
spacing: 0
|
||||
|
||||
|
||||
Repeater {
|
||||
model: root.code.split('\n').length
|
||||
|
||||
|
||||
Text {
|
||||
text: (index + 1).toString()
|
||||
color: Theme.onSurfaceVariant
|
||||
font.family: StyleVariables.fontMono
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: Theme.textSecondary
|
||||
font.family: Theme.fontFamilyMono
|
||||
font.pixelSize: 14
|
||||
horizontalAlignment: Text.AlignRight
|
||||
width: 30
|
||||
opacity: 0.5
|
||||
width: 32
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Code text
|
||||
Text {
|
||||
id: codeText
|
||||
text: root.code
|
||||
color: Theme.onSurface
|
||||
font.family: StyleVariables.fontMono
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: Theme.text
|
||||
font.family: Theme.fontFamilyMono
|
||||
font.pixelSize: 14
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
@@ -2,26 +2,26 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CCodeInline.qml - Inline code (mirrors _code-inline.scss)
|
||||
* Small inline code snippets
|
||||
* CCodeInline.qml - Material Design 3 inline code
|
||||
* Small inline code snippet with subtle background
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
|
||||
property alias text: label.text
|
||||
property alias textColor: label.color
|
||||
|
||||
color: Theme.mode === "dark" ? Qt.rgba(255, 255, 255, 0.1) : Qt.rgba(0, 0, 0, 0.06)
|
||||
radius: StyleVariables.radiusSm
|
||||
|
||||
implicitWidth: label.implicitWidth + StyleVariables.spacingSm * 2
|
||||
implicitHeight: label.implicitHeight + StyleVariables.spacingXs
|
||||
|
||||
|
||||
color: Theme.surface
|
||||
radius: 4
|
||||
|
||||
implicitWidth: label.implicitWidth + 8
|
||||
implicitHeight: label.implicitHeight + 4
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
color: Theme.onSurface
|
||||
font.family: StyleVariables.fontMono
|
||||
font.pixelSize: StyleVariables.fontSizeSm * 0.9
|
||||
color: Theme.text
|
||||
font.family: Theme.fontFamilyMono
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,103 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CText.qml - Styled text component (mirrors SCSS text utilities)
|
||||
* Pre-configured text with typography variants and color options
|
||||
*
|
||||
* CText.qml - Material Design 3 typography component
|
||||
* Implements the full MD3 type scale with variant and color support
|
||||
*
|
||||
* Usage:
|
||||
* CText { text: "Body text" }
|
||||
* CText { variant: "h4"; text: "Heading" }
|
||||
* CText { variant: "caption"; color: "secondary" }
|
||||
* CText { variant: "caption"; colorVariant: "secondary" }
|
||||
* CText { variant: "body2"; mono: true }
|
||||
*/
|
||||
Text {
|
||||
id: root
|
||||
|
||||
|
||||
// Public properties
|
||||
property string variant: "body1" // h1-h6, subtitle1, subtitle2, body1, body2, caption, overline, button
|
||||
property string colorVariant: "primary" // primary, secondary, disabled, error, success, warning, info
|
||||
property string variant: "body1" // h1-h6, subtitle1, subtitle2, body1, body2, caption, overline, button
|
||||
property string colorVariant: "primary" // primary, secondary, disabled, error, success, warning, info, inherit
|
||||
property bool mono: false
|
||||
property bool truncate: false
|
||||
|
||||
// Get typography settings from mixins
|
||||
readonly property var _typography: StyleMixins.typography(variant)
|
||||
|
||||
// Apply typography
|
||||
font.pixelSize: _typography.size
|
||||
font.weight: _typography.weight
|
||||
font.letterSpacing: _typography.spacing
|
||||
font.family: mono ? StyleVariables.fontMono : StyleVariables.fontFamily
|
||||
|
||||
// Apply color
|
||||
|
||||
// MD3 Typography Scale
|
||||
font.pixelSize: {
|
||||
switch (variant) {
|
||||
case "h1": return 57
|
||||
case "h2": return 45
|
||||
case "h3": return 36
|
||||
case "h4": return 32
|
||||
case "h5": return 28
|
||||
case "h6": return 24
|
||||
case "subtitle1": return 16
|
||||
case "subtitle2": return 14
|
||||
case "body1": return 16
|
||||
case "body2": return 14
|
||||
case "caption": return 12
|
||||
case "overline": return 12
|
||||
case "button": return 14
|
||||
default: return 16
|
||||
}
|
||||
}
|
||||
|
||||
font.weight: {
|
||||
switch (variant) {
|
||||
case "h1": return Font.Normal
|
||||
case "h2": return Font.Normal
|
||||
case "h3": return Font.Normal
|
||||
case "h4": return Font.Normal
|
||||
case "h5": return Font.Normal
|
||||
case "h6": return Font.Medium
|
||||
case "subtitle1": return Font.Medium
|
||||
case "subtitle2": return Font.Medium
|
||||
case "body1": return Font.Normal
|
||||
case "body2": return Font.Normal
|
||||
case "caption": return Font.Normal
|
||||
case "overline": return Font.Medium
|
||||
case "button": return Font.Medium
|
||||
default: return Font.Normal
|
||||
}
|
||||
}
|
||||
|
||||
font.letterSpacing: {
|
||||
switch (variant) {
|
||||
case "h1": return -0.25
|
||||
case "h2": return 0
|
||||
case "h3": return 0
|
||||
case "h4": return 0
|
||||
case "h5": return 0
|
||||
case "h6": return 0
|
||||
case "subtitle1": return 0.15
|
||||
case "subtitle2": return 0.1
|
||||
case "body1": return 0.5
|
||||
case "body2": return 0.25
|
||||
case "caption": return 0.4
|
||||
case "overline": return 1.5
|
||||
case "button": return 0.1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
font.capitalization: variant === "overline" ? Font.AllUppercase : Font.MixedCase
|
||||
|
||||
font.family: mono ? Theme.fontFamilyMono : Theme.fontFamily
|
||||
|
||||
lineHeight: variant === "body1" ? 1.5 : 1.0
|
||||
lineHeightMode: Text.ProportionalHeight
|
||||
|
||||
// MD3 color mapping
|
||||
color: {
|
||||
switch (colorVariant) {
|
||||
case "secondary": return Theme.textSecondary
|
||||
case "disabled": return Theme.textDisabled
|
||||
case "error": return Theme.error
|
||||
case "success": return Theme.success
|
||||
case "warning": return Theme.warning
|
||||
case "info": return Theme.info
|
||||
case "inherit": return parent ? parent.color : Theme.text
|
||||
default: return Theme.text
|
||||
case "disabled": return Theme.textDisabled
|
||||
case "error": return Theme.error
|
||||
case "success": return Theme.success
|
||||
case "warning": return Theme.warning
|
||||
case "info": return Theme.info
|
||||
case "inherit": return parent ? parent.color : Theme.text
|
||||
default: return Theme.text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Truncation
|
||||
elide: truncate ? Text.ElideRight : Text.ElideNone
|
||||
maximumLineCount: truncate ? 1 : undefined
|
||||
|
||||
@@ -2,51 +2,22 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CTitle.qml - Title text (mirrors _title.scss)
|
||||
* Heading typography component
|
||||
* CTitle.qml - Material Design 3 heading wrapper
|
||||
* Convenience component around CText for section headings
|
||||
*
|
||||
* Usage:
|
||||
* CTitle { text: "Page Title" }
|
||||
* CTitle { level: "h3"; text: "Section" }
|
||||
* CTitle { level: "h5"; text: "Subsection"; gutterBottom: false }
|
||||
*/
|
||||
Text {
|
||||
CText {
|
||||
id: root
|
||||
|
||||
property string level: "h1" // h1, h2, h3, h4, h5, h6
|
||||
property bool gutterBottom: true // Add bottom margin
|
||||
|
||||
color: Theme.onSurface
|
||||
|
||||
property string level: "h1" // h1-h6
|
||||
property bool gutterBottom: true // Add bottom margin
|
||||
|
||||
variant: level
|
||||
wrapMode: Text.Wrap
|
||||
|
||||
// Typography based on level
|
||||
font.pixelSize: {
|
||||
switch (level) {
|
||||
case "h1": return 32
|
||||
case "h2": return 28
|
||||
case "h3": return 24
|
||||
case "h4": return 20
|
||||
case "h5": return 18
|
||||
case "h6": return 16
|
||||
default: return 32
|
||||
}
|
||||
}
|
||||
|
||||
font.weight: {
|
||||
switch (level) {
|
||||
case "h1": return Font.Bold
|
||||
case "h2": return Font.Bold
|
||||
case "h3": return Font.DemiBold
|
||||
case "h4": return Font.DemiBold
|
||||
case "h5": return Font.Medium
|
||||
case "h6": return Font.Medium
|
||||
default: return Font.Bold
|
||||
}
|
||||
}
|
||||
|
||||
lineHeight: {
|
||||
switch (level) {
|
||||
case "h1": return 1.2
|
||||
case "h2": return 1.25
|
||||
case "h3": return 1.3
|
||||
default: return 1.4
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bottomPadding: gutterBottom ? StyleVariables.spacingSm : 0
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import "../theming"
|
||||
|
||||
Rectangle {
|
||||
id: listItem
|
||||
|
||||
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
property string caption: ""
|
||||
@@ -14,20 +15,30 @@ Rectangle {
|
||||
property bool selected: false
|
||||
property bool showDivider: true
|
||||
property alias trailing: trailingLoader.sourceComponent
|
||||
|
||||
|
||||
signal clicked()
|
||||
signal trailingClicked()
|
||||
|
||||
implicitHeight: Math.max(56, contentColumn.implicitHeight + 16)
|
||||
|
||||
color: {
|
||||
if (selected) return "#1a3a5c"
|
||||
if (mouseArea.containsMouse) return "#2d2d2d"
|
||||
return "transparent"
|
||||
|
||||
implicitHeight: subtitle || caption ? 56 : 48
|
||||
radius: 0
|
||||
color: "transparent"
|
||||
|
||||
// MD3 state layer
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 0
|
||||
|
||||
color: {
|
||||
if (listItem.selected)
|
||||
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
|
||||
if (mouseArea.containsMouse)
|
||||
return Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
|
||||
return "transparent"
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
@@ -35,80 +46,87 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: listItem.clicked()
|
||||
}
|
||||
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
spacing: 12
|
||||
|
||||
// Leading icon
|
||||
spacing: 16
|
||||
|
||||
// Leading element area (40px)
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 40
|
||||
Layout.preferredHeight: 40
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: 20
|
||||
color: "#2d2d2d"
|
||||
visible: listItem.leadingIcon
|
||||
|
||||
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
|
||||
visible: listItem.leadingIcon !== ""
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: listItem.leadingIcon
|
||||
font.pixelSize: 18
|
||||
color: Theme.primary
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Content
|
||||
ColumnLayout {
|
||||
id: contentColumn
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: 2
|
||||
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: listItem.title
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Medium
|
||||
color: "#ffffff"
|
||||
font.weight: Font.Normal
|
||||
color: listItem.selected ? Theme.primary : Theme.text
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: listItem.subtitle
|
||||
font.pixelSize: 12
|
||||
color: "#888888"
|
||||
color: Theme.textSecondary
|
||||
elide: Text.ElideRight
|
||||
visible: listItem.subtitle
|
||||
visible: listItem.subtitle !== ""
|
||||
}
|
||||
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: listItem.caption
|
||||
font.pixelSize: 11
|
||||
color: "#666666"
|
||||
color: Theme.textMuted
|
||||
elide: Text.ElideRight
|
||||
visible: listItem.caption
|
||||
visible: listItem.caption !== ""
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing
|
||||
|
||||
// Trailing text
|
||||
Text {
|
||||
text: listItem.trailingText
|
||||
font.pixelSize: 12
|
||||
color: "#888888"
|
||||
visible: listItem.trailingText
|
||||
color: Theme.textSecondary
|
||||
visible: listItem.trailingText !== ""
|
||||
}
|
||||
|
||||
|
||||
// Trailing custom content
|
||||
Loader {
|
||||
id: trailingLoader
|
||||
}
|
||||
|
||||
|
||||
// Trailing icon
|
||||
Text {
|
||||
text: listItem.trailingIcon
|
||||
font.pixelSize: 16
|
||||
color: trailingMouseArea.containsMouse ? "#ffffff" : "#888888"
|
||||
visible: listItem.trailingIcon
|
||||
|
||||
color: trailingMouseArea.containsMouse ? Theme.text : Theme.textSecondary
|
||||
visible: listItem.trailingIcon !== ""
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
MouseArea {
|
||||
id: trailingMouseArea
|
||||
anchors.fill: parent
|
||||
@@ -122,15 +140,17 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Divider
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: listItem.leadingIcon ? 68 : 16
|
||||
anchors.leftMargin: listItem.leadingIcon !== "" ? 72 : 16
|
||||
anchors.rightMargin: 16
|
||||
height: 1
|
||||
color: "#2d2d2d"
|
||||
color: Theme.border
|
||||
opacity: 0.5
|
||||
visible: listItem.showDivider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,59 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CAvatar.qml - Circular avatar (mirrors _avatar.scss)
|
||||
* Displays image or initials in circular container
|
||||
* CAvatar.qml - Material Design 3 circular avatar
|
||||
* Displays image or initials in a circular container with tonal surface
|
||||
*
|
||||
* Usage:
|
||||
* CAvatar { initials: "JD" } // Tonal primary with initials
|
||||
* CAvatar { src: "avatar.png" } // Image avatar
|
||||
* CAvatar { size: "lg"; initials: "AB" } // Large avatar
|
||||
* CAvatar { size: "sm"; bgColor: Theme.error } // Custom color
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
|
||||
property string size: "md" // sm, md, lg
|
||||
property string src: "" // Image source URL
|
||||
property string initials: "" // Fallback initials (e.g. "JD")
|
||||
property color bgColor: Theme.surfaceVariant
|
||||
property color textColor: Theme.onSurfaceVariant
|
||||
|
||||
// Size mapping
|
||||
property color bgColor: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16)
|
||||
property color textColor: Theme.primary
|
||||
|
||||
// MD3 size mapping: sm=32, md=40, lg=56
|
||||
readonly property int _size: {
|
||||
switch (size) {
|
||||
case "sm": return 32
|
||||
case "lg": return 64
|
||||
default: return 48
|
||||
case "lg": return 56
|
||||
default: return 40
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MD3 font size: scales with avatar size
|
||||
readonly property int _fontSize: {
|
||||
switch (size) {
|
||||
case "sm": return 13
|
||||
case "lg": return 22
|
||||
default: return 16
|
||||
}
|
||||
}
|
||||
|
||||
width: _size
|
||||
height: _size
|
||||
radius: _size / 2
|
||||
color: src ? "transparent" : bgColor
|
||||
color: src !== "" ? "transparent" : bgColor
|
||||
clip: true
|
||||
|
||||
// Image (if src provided)
|
||||
|
||||
// Image avatar
|
||||
Image {
|
||||
id: avatarImage
|
||||
anchors.fill: parent
|
||||
source: root.src
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: root.src !== ""
|
||||
|
||||
// Smooth circular clipping
|
||||
visible: root.src !== "" && status === Image.Ready
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
|
||||
// Circular clipping via layer
|
||||
layer.enabled: true
|
||||
layer.effect: Item {
|
||||
Rectangle {
|
||||
@@ -45,14 +63,33 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initials fallback
|
||||
|
||||
// Placeholder shown while image loads or on error
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: root.bgColor
|
||||
visible: root.src !== "" && avatarImage.status !== Image.Ready
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.initials.toUpperCase()
|
||||
color: root.textColor
|
||||
font.pixelSize: root._fontSize
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
visible: root.initials !== ""
|
||||
}
|
||||
}
|
||||
|
||||
// Initials fallback (no src)
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.initials.toUpperCase()
|
||||
color: root.textColor
|
||||
font.pixelSize: root._size * 0.4
|
||||
font.pixelSize: root._fontSize
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
visible: root.src === "" && root.initials !== ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,31 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CDivider.qml - Divider/separator component (mirrors _divider.scss)
|
||||
* Horizontal or vertical line separator
|
||||
*
|
||||
* CDivider.qml - Material Design 3 divider
|
||||
* Thin line separator, horizontal or vertical
|
||||
*
|
||||
* Usage:
|
||||
* CDivider {} // Horizontal divider
|
||||
* CDivider { orientation: "vertical" } // Vertical divider
|
||||
* CDivider { text: "OR" } // Divider with text
|
||||
* CDivider {} // Full-width horizontal
|
||||
* CDivider { variant: "inset" } // 16px left inset
|
||||
* CDivider { orientation: "vertical" } // Vertical divider
|
||||
* CDivider { text: "OR" } // Divider with centered text
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
|
||||
// Public properties
|
||||
property string orientation: "horizontal" // horizontal, vertical
|
||||
property string text: ""
|
||||
property string variant: "fullWidth" // fullWidth, inset, middle
|
||||
property int inset: StyleVariables.spacingLg
|
||||
|
||||
property int inset: 16
|
||||
|
||||
// MD3: 1px line using outlineVariant (softer than border)
|
||||
readonly property color _lineColor: Theme.border
|
||||
|
||||
// Size
|
||||
implicitWidth: orientation === "horizontal" ? 200 : 1
|
||||
implicitHeight: orientation === "horizontal" ? (text ? 24 : 1) : 200
|
||||
|
||||
|
||||
// Horizontal divider
|
||||
Row {
|
||||
visible: root.orientation === "horizontal"
|
||||
@@ -30,42 +34,43 @@ Item {
|
||||
anchors.leftMargin: root.variant === "inset" ? root.inset : (root.variant === "middle" ? root.inset : 0)
|
||||
anchors.rightMargin: root.variant === "middle" ? root.inset : 0
|
||||
spacing: root.text ? StyleVariables.spacingMd : 0
|
||||
|
||||
|
||||
// Left line
|
||||
Rectangle {
|
||||
width: root.text ? (parent.width - textLabel.width - StyleVariables.spacingMd * 2) / 2 : parent.width
|
||||
height: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.divider
|
||||
color: root._lineColor
|
||||
}
|
||||
|
||||
|
||||
// Text label
|
||||
Text {
|
||||
id: textLabel
|
||||
visible: root.text
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
font.pixelSize: StyleVariables.fontSizeXs
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
color: Theme.textSecondary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
|
||||
// Right line
|
||||
Rectangle {
|
||||
visible: root.text
|
||||
visible: root.text !== ""
|
||||
width: (parent.width - textLabel.width - StyleVariables.spacingMd * 2) / 2
|
||||
height: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.divider
|
||||
color: root._lineColor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Vertical divider
|
||||
Rectangle {
|
||||
visible: root.orientation === "vertical"
|
||||
width: 1
|
||||
height: parent.height
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: Theme.divider
|
||||
color: root._lineColor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,116 +3,196 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CTable.qml - Data table (mirrors _table.scss)
|
||||
* Simple table with headers and rows
|
||||
* CTable.qml - Material Design 3 data table
|
||||
* Surface container header, 48px rows, hover state layer, outlineVariant borders
|
||||
*
|
||||
* Usage:
|
||||
* CTable {
|
||||
* headers: ["Name", "Email", "Role"]
|
||||
* rows: [["Alice", "alice@co", "Admin"], ["Bob", "bob@co", "User"]]
|
||||
* sortColumn: 0; sortAscending: true
|
||||
* }
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
|
||||
property var headers: [] // Array of header strings
|
||||
property var rows: [] // Array of row arrays
|
||||
property var columnWidths: [] // Optional column width ratios
|
||||
property bool striped: true
|
||||
property bool bordered: true
|
||||
|
||||
|
||||
// MD3 sort support
|
||||
property int sortColumn: -1 // Column index currently sorted (-1 = none)
|
||||
property bool sortAscending: true
|
||||
signal headerClicked(int columnIndex)
|
||||
|
||||
color: "transparent"
|
||||
radius: StyleVariables.radiusSm
|
||||
border.width: bordered ? 1 : 0
|
||||
border.color: Theme.divider
|
||||
|
||||
border.color: Theme.border
|
||||
|
||||
implicitWidth: parent ? parent.width : 400
|
||||
implicitHeight: tableCol.implicitHeight
|
||||
|
||||
|
||||
clip: true
|
||||
|
||||
|
||||
ColumnLayout {
|
||||
id: tableCol
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// Header row
|
||||
|
||||
// Header row - MD3 surfaceContainer background
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: headerRow.implicitHeight
|
||||
color: Theme.mode === "dark" ? Qt.rgba(255, 255, 255, 0.08) : Qt.rgba(0, 0, 0, 0.04)
|
||||
|
||||
implicitHeight: 48
|
||||
color: Theme.mode === "dark"
|
||||
? Qt.rgba(1, 1, 1, 0.08)
|
||||
: Qt.rgba(0, 0, 0, 0.04)
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
|
||||
Repeater {
|
||||
model: root.headers
|
||||
|
||||
Rectangle {
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: root.columnWidths.length === 0
|
||||
Layout.preferredWidth: root.columnWidths[index] || -1
|
||||
implicitHeight: headerText.implicitHeight + StyleVariables.spacingSm * 2
|
||||
color: "transparent"
|
||||
border.width: root.bordered && index > 0 ? 1 : 0
|
||||
border.color: Theme.divider
|
||||
|
||||
Text {
|
||||
id: headerText
|
||||
Layout.preferredWidth: root.columnWidths.length > index ? root.columnWidths[index] : -1
|
||||
implicitHeight: 48
|
||||
|
||||
// MD3 header cell content
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: modelData
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.fontFamily
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Sort indicator arrow
|
||||
Text {
|
||||
visible: root.sortColumn === index
|
||||
text: root.sortAscending ? "\u25B2" : "\u25BC"
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Clickable area for sorting
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.margins: StyleVariables.spacingSm
|
||||
text: modelData
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.headerClicked(index)
|
||||
}
|
||||
|
||||
// Column separator
|
||||
Rectangle {
|
||||
visible: root.bordered && index > 0
|
||||
width: 1
|
||||
height: parent.height
|
||||
anchors.left: parent.left
|
||||
color: Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bottom border under header
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.border
|
||||
}
|
||||
|
||||
// Data rows
|
||||
Repeater {
|
||||
model: root.rows
|
||||
|
||||
|
||||
Rectangle {
|
||||
id: rowDelegate
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: dataRow.implicitHeight
|
||||
color: root.striped && index % 2 === 1
|
||||
? (Theme.mode === "dark" ? Qt.rgba(255, 255, 255, 0.02) : Qt.rgba(0, 0, 0, 0.02))
|
||||
: "transparent"
|
||||
|
||||
// Top border
|
||||
implicitHeight: 48
|
||||
|
||||
property bool hovered: rowMouse.containsMouse
|
||||
|
||||
// MD3: alternating tint + hover state layer (4%)
|
||||
color: {
|
||||
if (hovered)
|
||||
return Theme.mode === "dark"
|
||||
? Qt.rgba(1, 1, 1, 0.04)
|
||||
: Qt.rgba(0, 0, 0, 0.04)
|
||||
if (root.striped && index % 2 === 1)
|
||||
return Theme.mode === "dark"
|
||||
? Qt.rgba(1, 1, 1, 0.02)
|
||||
: Qt.rgba(0, 0, 0, 0.02)
|
||||
return "transparent"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
// Row border (outlineVariant between rows)
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: root.bordered ? 1 : 0
|
||||
color: Theme.divider
|
||||
anchors.bottom: parent.bottom
|
||||
color: Theme.border
|
||||
}
|
||||
|
||||
|
||||
RowLayout {
|
||||
id: dataRow
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: root.bordered ? 1 : 0
|
||||
spacing: 0
|
||||
|
||||
|
||||
Repeater {
|
||||
model: modelData
|
||||
|
||||
Rectangle {
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: root.columnWidths.length === 0
|
||||
Layout.preferredWidth: root.columnWidths[index] || -1
|
||||
implicitHeight: cellText.implicitHeight + StyleVariables.spacingSm * 2
|
||||
color: "transparent"
|
||||
border.width: root.bordered && index > 0 ? 1 : 0
|
||||
border.color: Theme.divider
|
||||
|
||||
Layout.preferredWidth: root.columnWidths.length > index ? root.columnWidths[index] : -1
|
||||
implicitHeight: 48
|
||||
|
||||
Text {
|
||||
id: cellText
|
||||
anchors.fill: parent
|
||||
anchors.margins: StyleVariables.spacingSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
text: modelData
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: Theme.text
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
// Column separator
|
||||
Rectangle {
|
||||
visible: root.bordered && index > 0
|
||||
width: 1
|
||||
height: parent.height
|
||||
anchors.left: parent.left
|
||||
color: Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CAlert.qml - Alert/notification component (mirrors _alert.scss)
|
||||
* CAlert.qml - Material Design 3 Alert component
|
||||
* Displays contextual feedback messages with severity levels
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* CAlert {
|
||||
* severity: "error"
|
||||
@@ -15,7 +15,7 @@ import QmlComponents 1.0
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
|
||||
// Public properties
|
||||
property string text: ""
|
||||
property string title: ""
|
||||
@@ -23,10 +23,10 @@ Rectangle {
|
||||
property string icon: "" // Custom icon, auto-selected if empty
|
||||
property bool closable: false
|
||||
property string variant: "filled" // filled, outlined, standard
|
||||
|
||||
|
||||
// Signals
|
||||
signal closed()
|
||||
|
||||
|
||||
// Auto-select icon based on severity
|
||||
readonly property string _effectiveIcon: icon || {
|
||||
"info": "ℹ️",
|
||||
@@ -34,91 +34,128 @@ Rectangle {
|
||||
"warning": "⚠️",
|
||||
"error": "❌"
|
||||
}[severity] || "ℹ️"
|
||||
|
||||
// Get colors based on severity
|
||||
|
||||
// MD3 severity color
|
||||
readonly property color _severityColor: {
|
||||
switch (severity) {
|
||||
case "success": return Theme.success
|
||||
case "error": return Theme.error
|
||||
case "warning": return Theme.warning
|
||||
case "info": return Theme.info
|
||||
default: return Theme.info
|
||||
}
|
||||
}
|
||||
|
||||
// MD3 tonal background based on severity (12% opacity)
|
||||
readonly property color _bgColor: {
|
||||
if (variant === "outlined") return "transparent"
|
||||
if (variant === "standard") return Qt.rgba(_accentColor.r, _accentColor.g, _accentColor.b, 0.08)
|
||||
// filled
|
||||
return Qt.rgba(_accentColor.r, _accentColor.g, _accentColor.b, 0.15)
|
||||
if (variant === "standard") return Qt.rgba(_severityColor.r, _severityColor.g, _severityColor.b, 0.08)
|
||||
// filled — MD3 tonal surface
|
||||
return Qt.rgba(_severityColor.r, _severityColor.g, _severityColor.b, 0.12)
|
||||
}
|
||||
|
||||
readonly property color _accentColor: StyleMixins.statusColor(severity)
|
||||
|
||||
|
||||
readonly property color _accentColor: _severityColor
|
||||
|
||||
readonly property color _textColor: {
|
||||
if (variant === "filled") return _accentColor
|
||||
if (variant === "filled") return _severityColor
|
||||
return Theme.text
|
||||
}
|
||||
|
||||
|
||||
// Size and appearance
|
||||
implicitHeight: contentLayout.implicitHeight + StyleVariables.spacingMd * 2
|
||||
implicitHeight: contentLayout.implicitHeight + 16 * 2
|
||||
implicitWidth: 300
|
||||
|
||||
|
||||
color: _bgColor
|
||||
radius: StyleVariables.radiusSm
|
||||
radius: 12
|
||||
border.width: variant === "outlined" ? 1 : 0
|
||||
border.color: _accentColor
|
||||
|
||||
|
||||
// MD3 left accent bar
|
||||
Rectangle {
|
||||
id: accentBar
|
||||
width: 4
|
||||
height: parent.height - 8
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 2
|
||||
color: root._severityColor
|
||||
visible: root.variant !== "outlined"
|
||||
}
|
||||
|
||||
// Content
|
||||
RowLayout {
|
||||
id: contentLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: StyleVariables.spacingMd
|
||||
spacing: StyleVariables.spacingSm
|
||||
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
anchors.topMargin: 16
|
||||
anchors.bottomMargin: 16
|
||||
spacing: 12
|
||||
|
||||
// Icon
|
||||
Text {
|
||||
text: root._effectiveIcon
|
||||
font.pixelSize: StyleVariables.fontSizeLg
|
||||
font.pixelSize: 20
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
|
||||
// Text content
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: StyleVariables.spacingXs
|
||||
|
||||
spacing: 4
|
||||
|
||||
// Title (optional)
|
||||
Text {
|
||||
visible: root.title
|
||||
visible: root.title !== ""
|
||||
text: root.title
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
font.weight: Font.Bold
|
||||
color: root._textColor
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
color: root._severityColor
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
|
||||
// Message
|
||||
Text {
|
||||
text: root.text
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: root._textColor
|
||||
font.pixelSize: 14
|
||||
color: root.title ? Theme.text : root._textColor
|
||||
opacity: root.title ? 0.9 : 1.0
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.4
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Close button
|
||||
Text {
|
||||
Rectangle {
|
||||
visible: root.closable
|
||||
text: "✕"
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: Theme.textSecondary
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: closeArea.containsMouse ? Qt.rgba(root._severityColor.r, root._severityColor.g, root._severityColor.b, 0.12) : "transparent"
|
||||
Layout.alignment: Qt.AlignTop
|
||||
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✕"
|
||||
font.pixelSize: 14
|
||||
color: Theme.textSecondary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -8
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: root.closed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Entry animation
|
||||
opacity: 0
|
||||
Component.onCompleted: opacity = 1
|
||||
Behavior on opacity { NumberAnimation { duration: StyleVariables.transitionNormal } }
|
||||
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
|
||||
@@ -4,124 +4,154 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CDialog.qml - Modal dialog (mirrors _dialog.scss)
|
||||
* CDialog.qml - Material Design 3 Dialog
|
||||
* Overlay dialog with header, content, and footer
|
||||
*/
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
|
||||
property string title: ""
|
||||
property string size: "md" // sm, md, lg, xl
|
||||
property bool showClose: true
|
||||
property alias dialogContent: contentArea.data
|
||||
property alias footerItem: footerArea.data
|
||||
|
||||
// Size mapping
|
||||
|
||||
// MD3 size mapping (min 280, max 560 for default)
|
||||
readonly property int _maxWidth: {
|
||||
switch (size) {
|
||||
case "sm": return 400
|
||||
case "lg": return 800
|
||||
case "xl": return 1000
|
||||
default: return 640
|
||||
case "sm": return 360
|
||||
case "lg": return 560
|
||||
case "xl": return 560
|
||||
default: return 480
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
readonly property int _minWidth: 280
|
||||
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
anchors.centerIn: parent
|
||||
|
||||
width: Math.min(_maxWidth, parent.width - StyleVariables.spacingLg * 2)
|
||||
|
||||
// Fade in animation
|
||||
|
||||
width: Math.max(_minWidth, Math.min(_maxWidth, parent.width - 48))
|
||||
|
||||
// MD3 open animation: scale + opacity
|
||||
enter: Transition {
|
||||
ParallelAnimation {
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 150 }
|
||||
NumberAnimation { property: "y"; from: root.y - 20; to: root.y; duration: 200; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 250; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { property: "scale"; from: 0.85; to: 1.0; duration: 250; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MD3 close animation
|
||||
exit: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 100 }
|
||||
ParallelAnimation {
|
||||
NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 200; easing.type: Easing.InCubic }
|
||||
NumberAnimation { property: "scale"; from: 1.0; to: 0.85; duration: 200; easing.type: Easing.InCubic }
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay background
|
||||
|
||||
// MD3 scrim/backdrop
|
||||
Overlay.modal: Rectangle {
|
||||
color: Qt.rgba(0, 0, 0, 0.6)
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
color: Qt.rgba(0, 0, 0, 0.4)
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 200 } }
|
||||
}
|
||||
|
||||
|
||||
// MD3 surface container background with large radius
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
radius: StyleVariables.radiusLg
|
||||
|
||||
radius: 28
|
||||
|
||||
// MD3 elevation shadow
|
||||
layer.enabled: true
|
||||
layer.effect: Item {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -10
|
||||
anchors.margins: -8
|
||||
color: "transparent"
|
||||
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
anchors.margins: 8
|
||||
color: "#000000"
|
||||
opacity: 0.3
|
||||
radius: StyleVariables.radiusLg + 4
|
||||
opacity: 0.18
|
||||
radius: 32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: 0
|
||||
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: StyleVariables.spacingMd
|
||||
Layout.bottomMargin: 0
|
||||
spacing: StyleVariables.spacingMd
|
||||
Layout.topMargin: 24
|
||||
Layout.leftMargin: 24
|
||||
Layout.rightMargin: 24
|
||||
Layout.bottomMargin: 16
|
||||
spacing: 0
|
||||
visible: root.title !== "" || root.showClose
|
||||
|
||||
Text {
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
text: root.title
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: StyleVariables.fontSizeLg
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
CIconButton {
|
||||
visible: root.showClose
|
||||
icon: "✕"
|
||||
size: "sm"
|
||||
onClicked: root.close()
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.title
|
||||
color: Theme.text
|
||||
font.pixelSize: 24
|
||||
font.weight: Font.Bold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// MD3 close button (circular, subtle)
|
||||
Rectangle {
|
||||
visible: root.showClose
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 16
|
||||
color: closeHover.containsMouse ? Theme.actionHover : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✕"
|
||||
font.pixelSize: 16
|
||||
color: Theme.textSecondary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Divider after header
|
||||
CDivider {
|
||||
Layout.fillWidth: true
|
||||
visible: root.title !== ""
|
||||
}
|
||||
|
||||
|
||||
// Content area
|
||||
Item {
|
||||
id: contentArea
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.margins: StyleVariables.spacingLg
|
||||
Layout.leftMargin: 24
|
||||
Layout.rightMargin: 24
|
||||
Layout.bottomMargin: 8
|
||||
implicitHeight: childrenRect.height
|
||||
}
|
||||
|
||||
// Footer (optional)
|
||||
|
||||
// Footer with right-aligned text buttons (MD3 pattern)
|
||||
Item {
|
||||
id: footerArea
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: StyleVariables.spacingMd
|
||||
Layout.topMargin: 0
|
||||
Layout.leftMargin: 24
|
||||
Layout.rightMargin: 24
|
||||
Layout.bottomMargin: 24
|
||||
Layout.topMargin: 8
|
||||
implicitHeight: childrenRect.height
|
||||
visible: children.length > 0
|
||||
}
|
||||
|
||||
@@ -4,49 +4,49 @@ import QtQuick.Effects
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CSnackbar.qml - Snackbar/toast notification (mirrors _snackbar.scss)
|
||||
* CSnackbar.qml - Material Design 3 Snackbar/toast notification
|
||||
* Brief messages at the bottom of the screen
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* CSnackbar {
|
||||
* id: snackbar
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // Show snackbar
|
||||
* snackbar.show("Message saved!", "success")
|
||||
* snackbar.show("Error occurred", "error", 5000, "Retry", () => { retry() })
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
|
||||
// Public properties
|
||||
property int duration: 4000 // Auto-hide duration in ms (0 = no auto-hide)
|
||||
property string position: "bottom" // bottom, top
|
||||
property int maxWidth: 400
|
||||
|
||||
property int maxWidth: 480
|
||||
|
||||
// Internal state
|
||||
property string _message: ""
|
||||
property string _severity: "default" // default, success, warning, error, info
|
||||
property string _actionText: ""
|
||||
property var _actionCallback: null
|
||||
property bool _visible: false
|
||||
|
||||
|
||||
// Signals
|
||||
signal actionClicked()
|
||||
|
||||
|
||||
// Size
|
||||
width: parent.width
|
||||
height: snackbarRect.height + StyleVariables.spacingLg * 2
|
||||
|
||||
height: snackbarRect.height + 24 * 2
|
||||
|
||||
// Position
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: position === "bottom" ? parent.bottom : undefined
|
||||
anchors.top: position === "top" ? parent.top : undefined
|
||||
|
||||
|
||||
// Z-index
|
||||
z: StyleVariables.zToast
|
||||
|
||||
|
||||
// Show snackbar
|
||||
function show(message, severity, customDuration, actionText, actionCallback) {
|
||||
_message = message || ""
|
||||
@@ -54,93 +54,125 @@ Item {
|
||||
_actionText = actionText || ""
|
||||
_actionCallback = actionCallback || null
|
||||
_visible = true
|
||||
|
||||
|
||||
// Start auto-hide timer
|
||||
if ((customDuration !== undefined ? customDuration : duration) > 0) {
|
||||
hideTimer.interval = customDuration !== undefined ? customDuration : duration
|
||||
hideTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Hide snackbar
|
||||
function hide() {
|
||||
_visible = false
|
||||
hideTimer.stop()
|
||||
}
|
||||
|
||||
|
||||
// Auto-hide timer
|
||||
Timer {
|
||||
id: hideTimer
|
||||
onTriggered: root.hide()
|
||||
}
|
||||
|
||||
|
||||
// MD3 inverse surface color
|
||||
readonly property color _inverseSurface: Theme.mode === "dark" ? "#E6E1E5" : "#313033"
|
||||
// MD3 inverse on surface (text color)
|
||||
readonly property color _inverseOnSurface: Theme.mode === "dark" ? "#313033" : "#F4EFF4"
|
||||
// MD3 inverse primary (action button color)
|
||||
readonly property color _inversePrimary: Theme.mode === "dark" ? Qt.darker(Theme.primary, 1.3) : Qt.lighter(Theme.primary, 1.4)
|
||||
|
||||
// Snackbar content
|
||||
Rectangle {
|
||||
id: snackbarRect
|
||||
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: root.position === "bottom" ? parent.bottom : undefined
|
||||
anchors.top: root.position === "top" ? parent.top : undefined
|
||||
anchors.margins: StyleVariables.spacingMd
|
||||
|
||||
width: Math.min(contentRow.implicitWidth + StyleVariables.spacingMd * 2, root.maxWidth)
|
||||
height: contentRow.implicitHeight + StyleVariables.spacingSm * 2
|
||||
|
||||
radius: StyleVariables.radiusSm
|
||||
color: {
|
||||
switch (root._severity) {
|
||||
case "success": return "#1b5e20"
|
||||
case "warning": return "#e65100"
|
||||
case "error": return "#b71c1c"
|
||||
case "info": return "#0d47a1"
|
||||
default: return "#323232"
|
||||
}
|
||||
}
|
||||
|
||||
// Shadow
|
||||
anchors.bottomMargin: 16
|
||||
anchors.topMargin: 16
|
||||
|
||||
width: Math.min(contentRow.implicitWidth + 16 * 2, root.maxWidth)
|
||||
height: contentRow.implicitHeight + 14 * 2
|
||||
|
||||
radius: 8
|
||||
color: root._inverseSurface
|
||||
|
||||
// MD3 elevation 3 shadow
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: "#60000000"
|
||||
shadowBlur: 0.3
|
||||
shadowVerticalOffset: 4
|
||||
shadowColor: "#40000000"
|
||||
shadowBlur: 0.4
|
||||
shadowVerticalOffset: 6
|
||||
shadowHorizontalOffset: 0
|
||||
}
|
||||
|
||||
// Visibility animation
|
||||
|
||||
// Slide-in animation from bottom/top
|
||||
opacity: root._visible ? 1 : 0
|
||||
y: root._visible ? 0 : (root.position === "bottom" ? 20 : -20)
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: StyleVariables.transitionNormal } }
|
||||
Behavior on y { NumberAnimation { duration: StyleVariables.transitionNormal; easing.type: Easing.OutCubic } }
|
||||
|
||||
transform: Translate {
|
||||
y: root._visible ? 0 : (root.position === "bottom" ? 60 : -60)
|
||||
|
||||
Behavior on y {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: root._visible ? 250 : 200
|
||||
easing.type: root._visible ? Easing.OutCubic : Easing.InCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
anchors.centerIn: parent
|
||||
spacing: StyleVariables.spacingMd
|
||||
|
||||
// Message
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
spacing: 12
|
||||
|
||||
// Message text — MD3 inverse on surface
|
||||
Text {
|
||||
text: root._message
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: "#ffffff"
|
||||
Layout.maximumWidth: root.maxWidth - StyleVariables.spacingMd * 4 - (actionButton.visible ? actionButton.width : 0)
|
||||
font.pixelSize: 14
|
||||
font.letterSpacing: 0.25
|
||||
color: root._inverseOnSurface
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: root.maxWidth - 16 * 3 - (actionButton.visible ? actionButton.width + 12 : 0)
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.4
|
||||
}
|
||||
|
||||
// Action button
|
||||
Text {
|
||||
|
||||
// Action button — MD3 inverse primary, text style
|
||||
Rectangle {
|
||||
id: actionButton
|
||||
visible: root._actionText
|
||||
text: root._actionText
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primaryLight
|
||||
|
||||
visible: root._actionText !== ""
|
||||
width: actionLabel.implicitWidth + 16
|
||||
height: actionLabel.implicitHeight + 12
|
||||
radius: 4
|
||||
color: actionArea.containsMouse ? Qt.rgba(root._inversePrimary.r, root._inversePrimary.g, root._inversePrimary.b, 0.12) : "transparent"
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.centerIn: parent
|
||||
text: root._actionText
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.1
|
||||
color: root._inversePrimary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: actionArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -StyleVariables.spacingXs
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
if (root._actionCallback) {
|
||||
root._actionCallback()
|
||||
@@ -151,8 +183,8 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close on click (optional)
|
||||
|
||||
// Dismiss on click
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
|
||||
@@ -4,49 +4,205 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CAutocomplete.qml - simple autocomplete input with popup suggestions
|
||||
* CAutocomplete.qml - Material Design 3 autocomplete input with popup suggestions
|
||||
* TextField + dropdown popup with 8px radius, surface background, and elevation
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
width: 300
|
||||
implicitHeight: input.height
|
||||
|
||||
property alias text: input.text
|
||||
property var suggestions: []
|
||||
property Component delegate: null
|
||||
property string placeholderText: "Type to search..."
|
||||
property bool loading: false
|
||||
|
||||
signal accepted(string value)
|
||||
|
||||
TextField {
|
||||
id: input
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
placeholderText: "Type to search..."
|
||||
placeholderText: root.placeholderText
|
||||
color: Theme.text
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
placeholderTextColor: Theme.textSecondary
|
||||
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: Theme.surface
|
||||
border.width: input.activeFocus ? 2 : 1
|
||||
border.color: input.activeFocus ? Theme.primary : Theme.border
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
Behavior on border.width {
|
||||
NumberAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
popup.open()
|
||||
if (text.length > 0 && root.suggestions.length > 0) {
|
||||
popup.open()
|
||||
} else {
|
||||
popup.close()
|
||||
}
|
||||
}
|
||||
onAccepted: root.accepted(text)
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: popup
|
||||
x: input.mapToItem(root, 0, input.height).x
|
||||
y: input.mapToItem(root, 0, input.height).y
|
||||
y: input.height + 4
|
||||
width: input.width
|
||||
modal: false
|
||||
focus: true
|
||||
focus: false
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
radius: 8
|
||||
border.color: Theme.border
|
||||
border.width: 1
|
||||
|
||||
// MD3 elevation shadow
|
||||
layer.enabled: true
|
||||
layer.effect: Item {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -2
|
||||
radius: 10
|
||||
color: "transparent"
|
||||
border.color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 2
|
||||
radius: 10
|
||||
color: Theme.shadowColor
|
||||
opacity: 0.15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ListView {
|
||||
id: list
|
||||
implicitHeight: Math.min(240, contentHeight)
|
||||
model: root.suggestions
|
||||
clip: true
|
||||
interactive: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: root.delegate ? root.delegate : defaultDelegate
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: defaultDelegate
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
color: Theme.surface
|
||||
radius: StyleVariables.radiusSm
|
||||
border.color: Theme.divider
|
||||
width: list.width
|
||||
height: 48
|
||||
color: delegateMouseArea.containsMouse ? Theme.actionHover : "transparent"
|
||||
radius: 0
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
width: parent.width
|
||||
model: root.suggestions
|
||||
delegate: root.delegate ? root.delegate : ItemDelegate { text: modelData; onClicked: { root.input.text = modelData; popup.close(); root.accepted(modelData); } }
|
||||
clip: true
|
||||
interactive: true
|
||||
height: Math.min(200, contentHeight)
|
||||
Text {
|
||||
id: delegateText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
text: modelData
|
||||
color: Theme.text
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Highlight matching text overlay
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
elide: Text.ElideRight
|
||||
color: "transparent"
|
||||
visible: input.text.length > 0
|
||||
|
||||
// Build rich text with highlighted match
|
||||
textFormat: Text.RichText
|
||||
text: {
|
||||
var src = modelData
|
||||
var query = input.text.toLowerCase()
|
||||
var idx = src.toLowerCase().indexOf(query)
|
||||
if (idx < 0) return ""
|
||||
var before = src.substring(0, idx)
|
||||
var match = src.substring(idx, idx + query.length)
|
||||
var after = src.substring(idx + query.length)
|
||||
return before + "<font color='" + Theme.primary + "'>" + match + "</font>" + after
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: draw the full text with highlighted portion
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
elide: Text.ElideRight
|
||||
textFormat: Text.RichText
|
||||
visible: input.text.length > 0
|
||||
text: {
|
||||
var src = modelData
|
||||
var query = input.text.toLowerCase()
|
||||
var idx = src.toLowerCase().indexOf(query)
|
||||
if (idx < 0) return "<font color='" + Theme.text + "'>" + src + "</font>"
|
||||
var before = src.substring(0, idx)
|
||||
var match = src.substring(idx, idx + query.length)
|
||||
var after = src.substring(idx + query.length)
|
||||
return "<font color='" + Theme.text + "'>" + before + "</font>"
|
||||
+ "<font color='" + Theme.primary + "'><b>" + match + "</b></font>"
|
||||
+ "<font color='" + Theme.text + "'>" + after + "</font>"
|
||||
}
|
||||
}
|
||||
|
||||
// Hide plain text when highlighted version is showing
|
||||
Component.onCompleted: {
|
||||
delegateText.visible = Qt.binding(function() { return input.text.length === 0 })
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: delegateMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.text = modelData
|
||||
popup.close()
|
||||
root.accepted(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// State layer ripple on press
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.primary
|
||||
opacity: delegateMouseArea.pressed ? 0.12 : 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,58 +3,78 @@ import QtQuick.Layouts
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CFormGroup.qml - Form field container (mirrors _form.scss)
|
||||
* Groups label, input, and helper/error text
|
||||
* CFormGroup.qml - Material Design 3 form field container
|
||||
* Groups label, input, and helper/error text with MD3 spacing
|
||||
*/
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
|
||||
property string label: ""
|
||||
property string helperText: ""
|
||||
property string errorText: ""
|
||||
property bool required: false
|
||||
property bool disabled: false
|
||||
|
||||
property bool focused: false
|
||||
|
||||
default property alias content: contentArea.data
|
||||
|
||||
spacing: StyleVariables.spacingXs
|
||||
|
||||
|
||||
spacing: 0
|
||||
|
||||
// Label
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Layout.bottomMargin: 4
|
||||
spacing: 0
|
||||
visible: root.label !== ""
|
||||
|
||||
|
||||
Text {
|
||||
text: root.label
|
||||
color: root.disabled ? Theme.onSurfaceVariant : Theme.onSurface
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
color: {
|
||||
if (root.disabled) return Theme.textDisabled
|
||||
if (root.errorText) return Theme.error
|
||||
if (root.focused) return Theme.primary
|
||||
return Theme.textSecondary
|
||||
}
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
opacity: root.disabled ? 0.6 : 1
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text {
|
||||
text: "*"
|
||||
text: " *"
|
||||
color: Theme.error
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
visible: root.required
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Content slot (for input)
|
||||
Item {
|
||||
id: contentArea
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: childrenRect.height
|
||||
}
|
||||
|
||||
|
||||
// Helper or error text
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: root.errorText || root.helperText
|
||||
color: root.errorText ? Theme.error : Theme.onSurfaceVariant
|
||||
font.pixelSize: StyleVariables.fontSizeXs
|
||||
color: root.errorText ? Theme.error : Theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.fontFamily
|
||||
visible: text !== ""
|
||||
wrapMode: Text.Wrap
|
||||
opacity: root.disabled ? 0.38 : 1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,46 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CFormLabel.qml - Material Design 3 form label
|
||||
* 12px medium-weight label with focus, error, and required states
|
||||
*/
|
||||
Text {
|
||||
id: label
|
||||
|
||||
property alias text: label.text
|
||||
property bool required: false
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: StyleVariables.fontSizeSm
|
||||
property bool focused: false
|
||||
property bool error: false
|
||||
property bool disabled: false
|
||||
|
||||
text: "Label"
|
||||
|
||||
color: {
|
||||
if (disabled) return Theme.textDisabled
|
||||
if (error) return Theme.error
|
||||
if (focused) return Theme.primary
|
||||
return Theme.textSecondary
|
||||
}
|
||||
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
opacity: disabled ? 0.38 : 1
|
||||
|
||||
// Append required asterisk via overlay to keep text property clean
|
||||
Text {
|
||||
anchors.left: parent.right
|
||||
anchors.baseline: parent.baseline
|
||||
text: " *"
|
||||
color: Theme.error
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
visible: label.required
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.transitionShortest }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,41 @@ import QtQuick
|
||||
import QmlComponents 1.0
|
||||
|
||||
/**
|
||||
* CLabel.qml - Form label (mirrors _label.scss)
|
||||
* Styled label for form inputs
|
||||
* CLabel.qml - Material Design 3 simple text label
|
||||
* 14px body label associated with a form control
|
||||
*/
|
||||
Text {
|
||||
id: root
|
||||
|
||||
|
||||
property string size: "md" // sm, md, lg
|
||||
property bool required: false
|
||||
property bool disabled: false
|
||||
|
||||
color: disabled ? Theme.onSurfaceVariant : Theme.onSurface
|
||||
opacity: disabled ? 0.6 : 1
|
||||
|
||||
property Item control: null
|
||||
|
||||
color: disabled ? Theme.textDisabled : Theme.text
|
||||
opacity: disabled ? 0.38 : 1
|
||||
|
||||
font.pixelSize: {
|
||||
switch (size) {
|
||||
case "sm": return StyleVariables.fontSizeXs
|
||||
case "lg": return StyleVariables.fontSizeMd
|
||||
default: return StyleVariables.fontSizeSm
|
||||
case "sm": return 12
|
||||
case "lg": return 16
|
||||
default: return 14
|
||||
}
|
||||
}
|
||||
font.weight: Font.Medium
|
||||
|
||||
font.weight: Font.Normal
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
// Append asterisk for required fields
|
||||
text: text + (required ? " *" : "")
|
||||
|
||||
// Clicking the label focuses the associated control
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: root.control ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.control && root.control.forceActiveFocus) {
|
||||
root.control.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,60 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import "../theming"
|
||||
|
||||
Row {
|
||||
id: root
|
||||
spacing: 8
|
||||
property var items: []
|
||||
|
||||
property var items: [] // ["Home", "Settings", "Profile"]
|
||||
property string separator: "/" // "/" or ">"
|
||||
|
||||
signal itemClicked(int index, string label)
|
||||
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.items
|
||||
delegate: Row {
|
||||
Text { text: modelData }
|
||||
Text { text: index < root.items.length-1 ? ">" : ""; color: "#888" }
|
||||
|
||||
Row {
|
||||
spacing: 0
|
||||
|
||||
// Breadcrumb item — last item is plain text, others are clickable
|
||||
Text {
|
||||
id: crumbText
|
||||
text: modelData
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Medium
|
||||
color: {
|
||||
if (index === root.items.length - 1)
|
||||
return Theme.text
|
||||
if (crumbMouse.containsMouse)
|
||||
return Theme.primary
|
||||
return Theme.textSecondary
|
||||
}
|
||||
opacity: index === root.items.length - 1 ? 1.0 : (crumbMouse.containsMouse ? 1.0 : 0.9)
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
MouseArea {
|
||||
id: crumbMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: index < root.items.length - 1
|
||||
cursorShape: index < root.items.length - 1 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: index < root.items.length - 1
|
||||
onClicked: root.itemClicked(index, modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// Separator — not shown after last item
|
||||
Text {
|
||||
text: " " + root.separator + " "
|
||||
font.pixelSize: 14
|
||||
color: Theme.textSecondary
|
||||
visible: index < root.items.length - 1
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,109 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import "../theming"
|
||||
|
||||
Rectangle {
|
||||
id: tabBar
|
||||
|
||||
|
||||
property int currentIndex: 0
|
||||
property var tabs: [] // [{label: "Tab 1", icon: "🏠"}]
|
||||
|
||||
property var tabs: [] // [{label: "Tab 1", icon: "home"}]
|
||||
|
||||
signal tabClicked(int index)
|
||||
|
||||
implicitHeight: 48
|
||||
color: "transparent"
|
||||
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
|
||||
Repeater {
|
||||
id: tabRepeater
|
||||
model: tabBar.tabs
|
||||
|
||||
|
||||
Rectangle {
|
||||
id: tabDelegate
|
||||
|
||||
readonly property bool isActive: tabBar.currentIndex === index
|
||||
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: tabText.implicitWidth + 32
|
||||
|
||||
color: tabBar.currentIndex === index ? "#2d2d2d" : (tabMouse.containsMouse ? "#252525" : "transparent")
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
ColumnLayout {
|
||||
Layout.preferredWidth: tabContent.implicitWidth + 32
|
||||
|
||||
color: "transparent"
|
||||
|
||||
// MD3 state layer on hover
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.text
|
||||
opacity: tabMouse.containsMouse && !tabDelegate.isActive ? 0.08 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: tabContent
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: modelData.icon || ""
|
||||
font.pixelSize: 14
|
||||
visible: modelData.icon
|
||||
}
|
||||
|
||||
Text {
|
||||
id: tabText
|
||||
text: modelData.label || modelData
|
||||
font.pixelSize: 13
|
||||
font.weight: tabBar.currentIndex === index ? Font.DemiBold : Font.Normal
|
||||
color: tabBar.currentIndex === index ? "#4dabf7" : "#888888"
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: modelData.icon || ""
|
||||
font.pixelSize: 14
|
||||
color: tabDelegate.isActive ? Theme.primary : Theme.textSecondary
|
||||
visible: modelData.icon !== undefined && modelData.icon !== ""
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.label || modelData
|
||||
font.pixelSize: 14
|
||||
font.weight: tabDelegate.isActive ? Font.DemiBold : Font.Medium
|
||||
color: tabDelegate.isActive ? Theme.primary : Theme.textSecondary
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
|
||||
// Active indicator
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 16
|
||||
height: 3
|
||||
radius: 1.5
|
||||
color: "#4dabf7"
|
||||
visible: tabBar.currentIndex === index
|
||||
}
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: tabMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: tabBar.currentIndex = index
|
||||
onClicked: {
|
||||
tabBar.currentIndex = index
|
||||
tabBar.tabClicked(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
|
||||
// MD3 active indicator pill — animates x and width smoothly
|
||||
Rectangle {
|
||||
id: activeIndicator
|
||||
anchors.bottom: parent.bottom
|
||||
height: 3
|
||||
radius: 1.5
|
||||
color: Theme.primary
|
||||
visible: tabRepeater.count > 0
|
||||
|
||||
property Item targetTab: tabRepeater.count > 0 && tabBar.currentIndex >= 0 && tabBar.currentIndex < tabRepeater.count
|
||||
? tabRepeater.itemAt(tabBar.currentIndex) : null
|
||||
|
||||
x: targetTab ? targetTab.x + 8 : 0
|
||||
width: targetTab ? targetTab.width - 16 : 0
|
||||
|
||||
Behavior on x { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
|
||||
Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
|
||||
// Subtle bottom divider
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 1
|
||||
color: "#2d2d2d"
|
||||
color: Theme.border
|
||||
opacity: 0.4
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user