feat: Implement Linear and Circular Progress components with styles

This commit is contained in:
2025-12-30 11:19:03 +00:00
parent 9d27207fbc
commit ede65e3694
11 changed files with 545 additions and 5 deletions

View File

@@ -1,14 +1,133 @@
import React from 'react'
import styles from '../../styles/components/Progress.module.scss'
export interface LinearProgressProps extends React.HTMLAttributes<HTMLDivElement> {
/** Current progress value (0-100) */
value?: number
/** Buffer value for buffered progress (0-100) */
valueBuffer?: number
/** Variant determines the visual style */
variant?: 'determinate' | 'indeterminate' | 'buffer' | 'query'
/** Color theme */
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
/** @deprecated Use variant="indeterminate" instead */
indeterminate?: boolean
}
export const LinearProgress: React.FC<LinearProgressProps> = ({ value, indeterminate, className = '', ...props }) => (
<div className={`progress ${indeterminate ? 'progress--indeterminate' : ''} ${className}`} {...props}>
<div className="progress-bar" style={value !== undefined ? { width: `${value}%` } : undefined} />
</div>
)
export const LinearProgress: React.FC<LinearProgressProps> = ({
value = 0,
valueBuffer,
variant,
color = 'primary',
indeterminate,
className = '',
...props
}) => {
// Support legacy indeterminate prop
const effectiveVariant = variant || (indeterminate ? 'indeterminate' : 'determinate')
const clampedValue = Math.min(100, Math.max(0, value))
const clampedBuffer = valueBuffer !== undefined ? Math.min(100, Math.max(0, valueBuffer)) : undefined
return (
<div
className={`${styles.linearProgress} ${styles[effectiveVariant]} ${styles[color]} ${className}`}
role="progressbar"
aria-valuenow={effectiveVariant === 'determinate' ? clampedValue : undefined}
aria-valuemin={0}
aria-valuemax={100}
{...props}
>
{effectiveVariant === 'buffer' && (
<>
<div className={styles.buffer} style={{ width: `${clampedBuffer ?? 0}%` }} />
<div className={styles.dashed} />
</>
)}
<div
className={styles.bar}
style={effectiveVariant === 'determinate' || effectiveVariant === 'buffer'
? { width: `${clampedValue}%` }
: undefined
}
/>
{(effectiveVariant === 'indeterminate' || effectiveVariant === 'query') && (
<div className={styles.bar2} />
)}
</div>
)
}
export interface CircularProgressProps extends React.HTMLAttributes<HTMLDivElement> {
/** Current progress value (0-100) for determinate variant */
value?: number
/** Variant determines the visual style */
variant?: 'determinate' | 'indeterminate'
/** Size of the progress circle */
size?: number | string
/** Thickness of the progress circle stroke */
thickness?: number
/** Color theme */
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'inherit'
/** Disable shrink animation on indeterminate */
disableShrink?: boolean
}
export const CircularProgress: React.FC<CircularProgressProps> = ({
value = 0,
variant = 'indeterminate',
size = 40,
thickness = 3.6,
color = 'primary',
disableShrink = false,
className = '',
style,
...props
}) => {
const clampedValue = Math.min(100, Math.max(0, value))
const circumference = 2 * Math.PI * ((44 - thickness) / 2)
const strokeDasharray = circumference.toFixed(3)
const strokeDashoffset = variant === 'determinate'
? (((100 - clampedValue) / 100) * circumference).toFixed(3)
: undefined
return (
<div
className={`${styles.circularProgress} ${styles[variant]} ${styles[`circular-${color}`]} ${disableShrink ? styles.disableShrink : ''} ${className}`}
role="progressbar"
aria-valuenow={variant === 'determinate' ? clampedValue : undefined}
aria-valuemin={0}
aria-valuemax={100}
style={{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
...style,
}}
{...props}
>
<svg viewBox="0 0 44 44" className={styles.svg}>
{/* Background circle (track) */}
<circle
className={styles.track}
cx={22}
cy={22}
r={(44 - thickness) / 2}
fill="none"
strokeWidth={thickness}
/>
{/* Progress circle */}
<circle
className={styles.circle}
cx={22}
cy={22}
r={(44 - thickness) / 2}
fill="none"
strokeWidth={thickness}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
</svg>
</div>
)
}
export const Progress = LinearProgress // alias

View File

@@ -0,0 +1,272 @@
// Progress component styles
// LinearProgress and CircularProgress
// Linear Progress
.linearProgress {
height: 4px;
width: 100%;
overflow: hidden;
position: relative;
background-color: var(--progress-track, rgba(var(--color-primary-rgb), 0.2));
border-radius: var(--radius-full);
.bar,
.bar2 {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: inherit;
transition: width 0.3s ease;
}
.bar {
background-color: var(--progress-bar, var(--color-primary));
}
.buffer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: var(--progress-buffer, rgba(var(--color-primary-rgb), 0.3));
border-radius: inherit;
}
.dashed {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(
var(--progress-track, rgba(var(--color-primary-rgb), 0.2)) 0%,
var(--progress-track, rgba(var(--color-primary-rgb), 0.2)) 16%,
transparent 42%
);
background-size: 10px 10px;
background-position: 0 -23px;
animation: dashed-animation 3s infinite linear;
}
// Determinate (default)
&.determinate {
.bar {
transition: width 0.4s linear;
}
}
// Indeterminate animation
&.indeterminate {
.bar {
width: auto;
animation: indeterminate-bar1 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.bar2 {
background-color: var(--progress-bar, var(--color-primary));
animation: indeterminate-bar2 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) 1.15s infinite;
}
}
// Query animation (reverse indeterminate)
&.query {
.bar {
width: auto;
animation: indeterminate-bar1 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite reverse;
}
.bar2 {
background-color: var(--progress-bar, var(--color-primary));
animation: indeterminate-bar2 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) 1.15s infinite reverse;
}
}
// Color variants
&.primary {
--progress-bar: var(--color-primary);
--progress-track: rgba(var(--color-primary-rgb), 0.2);
--progress-buffer: rgba(var(--color-primary-rgb), 0.4);
}
&.secondary {
--progress-bar: var(--color-secondary);
--progress-track: rgba(var(--color-secondary-rgb), 0.2);
--progress-buffer: rgba(var(--color-secondary-rgb), 0.4);
}
&.success {
--progress-bar: var(--color-success);
--progress-track: rgba(var(--color-success-rgb), 0.2);
--progress-buffer: rgba(var(--color-success-rgb), 0.4);
}
&.warning {
--progress-bar: var(--color-warning);
--progress-track: rgba(var(--color-warning-rgb), 0.2);
--progress-buffer: rgba(var(--color-warning-rgb), 0.4);
}
&.error {
--progress-bar: var(--color-error);
--progress-track: rgba(var(--color-error-rgb), 0.2);
--progress-buffer: rgba(var(--color-error-rgb), 0.4);
}
&.info {
--progress-bar: var(--color-info);
--progress-track: rgba(var(--color-info-rgb), 0.2);
--progress-buffer: rgba(var(--color-info-rgb), 0.4);
}
}
// Circular Progress
.circularProgress {
display: inline-flex;
position: relative;
.svg {
display: block;
}
.track {
stroke: var(--circular-track, rgba(var(--color-primary-rgb), 0.2));
}
.circle {
stroke: var(--circular-bar, var(--color-primary));
stroke-linecap: round;
}
// Determinate
&.determinate {
.svg {
transform: rotate(-90deg);
}
.circle {
transition: stroke-dashoffset 0.3s ease;
}
}
// Indeterminate
&.indeterminate {
animation: circular-rotate 1.4s linear infinite;
.circle {
animation: circular-dash 1.4s ease-in-out infinite;
stroke-dasharray: 80px, 200px;
stroke-dashoffset: 0;
}
}
&.disableShrink {
.circle {
animation: none;
}
}
// Color variants
&.circular-primary {
--circular-bar: var(--color-primary);
--circular-track: rgba(var(--color-primary-rgb), 0.2);
}
&.circular-secondary {
--circular-bar: var(--color-secondary);
--circular-track: rgba(var(--color-secondary-rgb), 0.2);
}
&.circular-success {
--circular-bar: var(--color-success);
--circular-track: rgba(var(--color-success-rgb), 0.2);
}
&.circular-warning {
--circular-bar: var(--color-warning);
--circular-track: rgba(var(--color-warning-rgb), 0.2);
}
&.circular-error {
--circular-bar: var(--color-error);
--circular-track: rgba(var(--color-error-rgb), 0.2);
}
&.circular-info {
--circular-bar: var(--color-info);
--circular-track: rgba(var(--color-info-rgb), 0.2);
}
&.circular-inherit {
--circular-bar: currentColor;
--circular-track: currentColor;
.track {
opacity: 0.2;
}
}
}
// Animations
@keyframes indeterminate-bar1 {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate-bar2 {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes dashed-animation {
0% {
background-position: 0 -23px;
}
100% {
background-position: -200px -23px;
}
}
@keyframes circular-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes circular-dash {
0% {
stroke-dasharray: 1px, 200px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -15px;
}
100% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -125px;
}
}

View File

@@ -0,0 +1,13 @@
--- Change user access level action
---@param user_id string User ID
---@param new_level number New access level (1-6)
---@return UserAction Action object
local function change_level(user_id, new_level)
return {
action = "change_level",
user_id = user_id,
level = new_level
}
end
return change_level

View File

@@ -0,0 +1,11 @@
--- Create a new user action
---@param data table User data
---@return UserAction Action object
local function create_user(data)
return {
action = "create_user",
data = data
}
end
return create_user

View File

@@ -0,0 +1,12 @@
--- Delete a user action
---@param user_id string User ID
---@return UserAction Action object
local function delete_user(user_id)
return {
action = "delete_user",
user_id = user_id,
confirm = true
}
end
return delete_user

View File

@@ -0,0 +1,14 @@
--- Get user table column definitions
---@return TableColumn[] Column definitions
local function get_columns()
return {
{ id = "username", label = "Username", sortable = true },
{ id = "email", label = "Email", sortable = true },
{ id = "role", label = "Role", sortable = true },
{ id = "level", label = "Level", sortable = true },
{ id = "active", label = "Status", type = "badge" },
{ id = "actions", label = "", type = "actions" }
}
end
return get_columns

View File

@@ -0,0 +1,15 @@
--- Render a single user row
---@param user User User object
---@return TableRow Table row data
local function render_row(user)
return {
username = user.username,
email = user.email,
role = user.role,
level = user.level,
active = user.active and "Active" or "Inactive",
actions = { "edit", "delete" }
}
end
return render_row

View File

@@ -0,0 +1,19 @@
local get_columns = require("get_columns")
local render_row = require("render_row")
--- Render users list as data table config
---@param users User[] Array of users
---@return DataTableConfig Table configuration
local function render_users(users)
local rows = {}
for _, user in ipairs(users) do
table.insert(rows, render_row(user))
end
return {
type = "data_table",
columns = get_columns(),
rows = rows
}
end
return render_users

View File

@@ -0,0 +1,13 @@
--- Toggle user active status action
---@param user_id string User ID
---@param active boolean New active status
---@return UserAction Action object
local function toggle_active(user_id, active)
return {
action = "toggle_active",
user_id = user_id,
active = active
}
end
return toggle_active

View File

@@ -0,0 +1,39 @@
---@meta
-- Type definitions for user_manager package
---@class UserAction
---@field action string Action type
---@field user_id? string Target user ID
---@field data? table Action data
---@field confirm? boolean Requires confirmation
---@field level? number Target access level
---@field active? boolean Active status
---@class User
---@field id string User ID
---@field username string Username
---@field email string Email address
---@field role string User role
---@field level number Access level (1-6)
---@field active boolean Whether user is active
---@class TableColumn
---@field id string Column identifier
---@field label string Column header label
---@field sortable? boolean Whether column is sortable
---@field type? string Column type (badge, actions, etc.)
---@class TableRow
---@field username string
---@field email string
---@field role string
---@field level number
---@field active string Status badge text
---@field actions string[] Available actions
---@class DataTableConfig
---@field type string Component type
---@field columns TableColumn[] Column definitions
---@field rows TableRow[] Row data
return {}

View File

@@ -0,0 +1,13 @@
--- Update an existing user action
---@param user_id string User ID
---@param data table Update data
---@return UserAction Action object
local function update_user(user_id, data)
return {
action = "update_user",
user_id = user_id,
data = data
}
end
return update_user