diff --git a/.claude/skills/core-development-skill/SKILL.md b/.claude/skills/core-development-skill/SKILL.md new file mode 100644 index 0000000000..b5f71641be --- /dev/null +++ b/.claude/skills/core-development-skill/SKILL.md @@ -0,0 +1,1268 @@ +# Tutor LMS — Core Development Skill + +## Overview + +This skill governs all development, bug fixing, and feature work inside the **Tutor LMS** plugin codebase (`github.com/themeum/tutor`). It covers project structure, namespace conventions, coding patterns, PHPDoc standards, available helpers/utilities, security rules, and AI agent instructions. + +**Always read and apply this skill before writing, editing, or reviewing any Tutor LMS PHP code.** + +**Repository:** https://github.com/themeum/tutor +**Text Domain:** `tutor` +**PHP Namespace (core classes):** `TUTOR` +**PHP Namespace (helpers/models):** `Tutor\Helpers`, `Tutor\Models`, `Tutor\Traits`, `Tutor\Cache`, `Tutor\Ecommerce` +**Current Version:** 3.9.6 (as of April 2026) +**Min PHP:** 7.4 | **Min WP:** 5.3 + +--- + +## PART 1 — PROJECT STRUCTURE + +``` +tutor/ +├── tutor.php # Plugin header, constants, boot only +├── composer.json # PHP autoloader (PSR-4) +├── vendor/ # Composer autoload +├── classes/ # Core TUTOR\ namespace classes +│ ├── Tutor.php # Main singleton (extends Singleton) +│ ├── Tutor_Base.php # Base class for content classes +│ ├── Singleton.php # Base Singleton class +│ ├── Utils.php # Global utility methods (tutor_utils()) +│ ├── Input.php # Sanitized input helper +│ ├── Course.php # Course operations +│ ├── Lesson.php # Lesson operations +│ ├── Quiz.php # Quiz operations +│ ├── User.php # User roles & operations +│ ├── Instructor.php # Instructor registration/management +│ ├── Enrollment.php # Enrollment logic +│ ├── Assets.php # Script/style enqueue +│ ├── Post_types.php # Post type registration +│ ├── Shortcode.php # Shortcode registration +│ ├── Ajax.php # Central AJAX handler registration +│ ├── Tools.php # Admin tools +│ └── ... # Other core classes +├── models/ # Tutor\Models namespace +│ ├── CourseModel.php +│ ├── LessonModel.php +│ ├── QuizModel.php +│ ├── EnrollmentModel.php +│ ├── UserModel.php +│ ├── WithdrawModel.php +│ └── ... +├── helpers/ # Tutor\Helpers namespace +│ ├── QueryHelper.php # DB query helpers +│ ├── HttpHelper.php # HTTP status codes + response helpers +│ ├── DateTimeHelper.php # Date/time utilities +│ ├── ValidationHelper.php # Input validation +│ └── ... +├── traits/ # Tutor\Traits namespace +│ ├── JsonResponse.php # json_response() method +│ └── ... +├── restapi/ # REST API controllers +├── ecommerce/ # Tutor\Ecommerce namespace +├── migrations/ # DB migration classes +├── cache/ # Tutor\Cache namespace (TutorCache) +├── templates/ # Frontend PHP templates +│ ├── dashboard/ # Dashboard page templates +│ ├── single-course/ # Course single page templates +│ └── ... +├── views/ # Admin UI views (output only) +├── assets/ +│ ├── css/ # Compiled CSS +│ ├── js/ # Compiled JS +│ └── lib/ # Third-party libs (select2, etc.) +├── v2-library/ # React/TS frontend source (rsbuild) +├── tests/ +│ ├── Unit/ +│ └── Integration/ +└── languages/ # Translation files (.pot/.po/.mo) +``` + +--- + +## PART 2 — NAMESPACES & AUTOLOADING + +Tutor LMS uses **PSR-4 autoloading** via Composer. Always declare the correct namespace at the top of every file. + +| Location | Namespace | +| ------------- | ------------------ | +| `classes/` | `TUTOR` | +| `models/` | `Tutor\Models` | +| `helpers/` | `Tutor\Helpers` | +| `traits/` | `Tutor\Traits` | +| `cache/` | `Tutor\Cache` | +| `ecommerce/` | `Tutor\Ecommerce` | +| `restapi/` | `Tutor\RestAPI` | +| `migrations/` | `Tutor\Migrations` | + +### Standard file header (all PHP files) + +```php + + * @link https://themeum.com + * @since x.x.x + */ + +namespace TUTOR; // or Tutor\Models, Tutor\Helpers, etc. + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use Tutor\Helpers\HttpHelper; +use Tutor\Helpers\QueryHelper; +use Tutor\Models\CourseModel; +use Tutor\Traits\JsonResponse; +``` + +--- + +## PART 3 — CORE BOOTSTRAP & SINGLETON PATTERN + +### Main plugin bootstrap (`tutor.php`) + +The main plugin file does **only** these four things: + +```php + load_plugin_textdomain( 'tutor', false, basename( __DIR__ ) . '/languages' ) ); + +register_activation_hook( TUTOR_FILE, array( Tutor::class, 'tutor_activate' ) ); +register_deactivation_hook( TUTOR_FILE, array( Tutor::class, 'tutor_deactivation' ) ); +register_uninstall_hook( TUTOR_FILE, array( Tutor::class, 'tutor_uninstall' ) ); + +function tutor_lms() { + return Tutor::get_instance(); +} + +$GLOBALS['tutor'] = tutor_lms(); +``` + +### Accessing the main instance + +```php +// Primary access — returns Tutor singleton +tutor(); // alias of tutor_lms() +tutor_lms(); // returns Tutor::get_instance() + +// Properties available on tutor() +tutor()->path // Plugin directory path +tutor()->url // Plugin directory URL +tutor()->version // Plugin version string +tutor()->has_pro // bool — Pro version active +tutor()->course_post_type // 'courses' +tutor()->lesson_post_type // 'lesson' +tutor()->nonce_action // Nonce action string +tutor()->nonce // Nonce key string +tutor()->utils // Utils class instance (same as tutor_utils()) +``` + +### Singleton base pattern + +All main service classes that need a single instance extend `Singleton`: + +```php +namespace TUTOR; + +final class My_Service extends Singleton { + + /** + * Initialize the service. + * + * @since 1.0.0 + * @return void + */ + protected function __construct() { + parent::__construct(); + // register hooks here + } +} + +// Usage — never use `new My_Service()` directly +My_Service::get_instance(); +``` + +--- + +## PART 4 — CLASS PATTERNS + +### 4.1 Content class (extends Tutor_Base) + +Use for classes that handle AJAX, hooks, and post-type operations (Course, Lesson, Quiz, etc.): + +```php +namespace TUTOR; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use Tutor\Helpers\HttpHelper; +use Tutor\Helpers\QueryHelper; +use Tutor\Models\CourseModel; +use Tutor\Traits\JsonResponse; + +/** + * Handle [Feature] operations. + * + * @package Tutor + * @author Themeum + * @link https://themeum.com + * @since 1.0.0 + */ +class My_Feature extends Tutor_Base { + + use JsonResponse; + + /** + * Register hooks. + * + * @since 1.0.0 + * + * @param bool $register_hooks Whether to register hooks. Default true. + * @return void + */ + public function __construct( $register_hooks = true ) { + parent::__construct(); + + if ( ! $register_hooks ) { + return; + } + + add_action( 'wp_ajax_tutor_my_action', array( $this, 'ajax_my_action' ) ); + add_action( 'wp_ajax_nopriv_tutor_my_public_action', array( $this, 'ajax_my_public_action' ) ); + } + + /** + * Handle my AJAX action. + * + * @since 1.0.0 + * @return void + */ + public function ajax_my_action() { + tutor_utils()->checking_nonce(); + + if ( ! current_user_can( 'edit_posts' ) ) { + $this->json_response( + tutor_utils()->error_message( 'forbidden' ), + null, + HttpHelper::STATUS_FORBIDDEN + ); + } + + // ... logic + $this->json_response( __( 'Success', 'tutor' ), $data ); + } +} +``` + +### 4.2 Model class (Tutor\Models namespace) + +Models handle only data access — no hooks, no output: + +```php +namespace Tutor\Models; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use Tutor\Helpers\QueryHelper; + +/** + * My Feature Model. + * + * @package Tutor\Models + * @author Themeum + * @link https://themeum.com + * @since x.x.x + */ +class MyFeatureModel { + + /** + * Table name without prefix. + * + * @since x.x.x + * @var string + */ + const TABLE = 'tutor_my_feature'; + + /** + * Get records by user ID. + * + * @since x.x.x + * + * @param int $user_id WordPress user ID. + * @param int $limit Max rows to return. Default -1 (all). + * @return array + */ + public static function get_by_user( int $user_id, int $limit = -1 ): array { + global $wpdb; + + return QueryHelper::get_all( + $wpdb->prefix . self::TABLE, + array( 'user_id' => $user_id ), + 'id', + $limit + ); + } + + /** + * Insert a new record. + * + * @since x.x.x + * + * @param array $data Associative array of column => value pairs. + * @return int|false Inserted row ID on success, false on failure. + */ + public static function create( array $data ) { + global $wpdb; + $inserted = QueryHelper::insert( $wpdb->prefix . self::TABLE, $data ); + return $inserted ? $wpdb->insert_id : false; + } +} +``` + +### 4.3 REST API controller + +```php +namespace Tutor\RestAPI; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +use Tutor\Helpers\HttpHelper; +use Tutor\Traits\JsonResponse; + +/** + * My Feature REST Controller. + * + * @package Tutor\RestAPI + * @since x.x.x + */ +class MyFeatureController extends \WP_REST_Controller { + + use JsonResponse; + + /** + * Namespace. + * + * @var string + */ + protected $namespace = 'tutor/v1'; + + /** + * Rest base. + * + * @var string + */ + protected $rest_base = 'my-feature'; + + /** + * Register routes. + * + * @since x.x.x + * @return void + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + ) + ); + } + + /** + * Check permissions. + * + * @since x.x.x + * + * @param \WP_REST_Request $request Full request data. + * @return bool|\WP_Error + */ + public function get_items_permissions_check( $request ) { + return current_user_can( 'manage_options' ); + } + + /** + * Get a collection of items. + * + * @since x.x.x + * + * @param \WP_REST_Request $request Full request data. + * @return \WP_REST_Response + */ + public function get_items( $request ) { + // ... logic + return rest_ensure_response( array( 'data' => $data ) ); + } +} +``` + +--- + +## PART 5 — AVAILABLE HELPERS & UTILITIES + +### 5.1 `tutor_utils()` / `tutils()` — Global Utility + +The `Utils` class is the primary utility hub. Access via `tutor_utils()` or its alias `tutils()`. + +```php +// Plugin options +tutor_utils()->get_option( 'option_key', $default ); +tutor_utils()->get_option( 'option_key', null, true, true ); // return bool + +// Nonce verification (dies on failure) +tutor_utils()->checking_nonce(); + +// Post ID resolution (uses current post if 0) +tutor_utils()->get_post_id( $post_id ); + +// User ID resolution (uses current user if 0) +tutor_utils()->get_user_id( $user_id ); + +// Enrollment checks +tutor_utils()->is_enrolled( $course_id, $user_id ); +tutor_utils()->is_instructor_of_this_course( $user_id, $course_id ); + +// Array utilities +tutor_utils()->array_get( 'key', $array, $default ); // safe array access +tutor_utils()->avalue_dot( 'parent.child', $array ); // dot notation access + +// Standardized error messages +tutor_utils()->error_message(); // Generic error +tutor_utils()->error_message( 'nonce' ); // Nonce failure message +tutor_utils()->error_message( 'forbidden' );// Permission denied message + +// Input (legacy — prefer Input:: class) +tutor_utils()->input_old( 'field_name' ); // POST/GET value with sanitization + +// Permissions +tutor_utils()->can_user_manage( 'course', $course_id ); +tutor_utils()->can_user_manage( 'topic', $topic_id ); + +// Dashboard +tutor_utils()->get_tutor_dashboard_page_permalink( $tab ); +tutor_utils()->course_edit_link( $course_id, 'frontend' ); + +// Time +tutor_time(); // current timestamp +``` + +### 5.2 `Input` class — Sanitized Input + +Always use `Input::` class to read request data. Never access `$_POST`, `$_GET`, `$_REQUEST` directly. + +```php +use TUTOR\Input; + +// GET: string (default) +$page = Input::get( 'page', '' ); + +// GET: integer +$id = Input::get( 'id', 0, Input::TYPE_INT ); + +// GET: boolean +$active = Input::get( 'active', false, Input::TYPE_BOOL ); + +// POST: string +$title = Input::post( 'title', '' ); + +// POST: integer +$count = Input::post( 'count', 0, Input::TYPE_INT ); + +// POST: sanitized array +$ids = Input::post( 'ids', array(), Input::TYPE_ARRAY ); + +// Any request method +$value = Input::sanitize_data( $raw_value ); +``` + +Available type constants: `Input::TYPE_INT`, `Input::TYPE_BOOL`, `Input::TYPE_FLOAT`, `Input::TYPE_ARRAY`, `Input::TYPE_EMAIL`, `Input::TYPE_URL`. + +### 5.3 `QueryHelper` — Database Abstraction + +```php +use Tutor\Helpers\QueryHelper; + +global $wpdb; + +// SELECT all rows matching conditions +$rows = QueryHelper::get_all( + $wpdb->prefix . 'tutor_enrollments', + array( 'course_id' => $course_id ), // WHERE conditions + 'id', // ORDER BY column + 10 // limit (-1 = all) +); + +// SELECT single row +$row = QueryHelper::get_row( + $wpdb->prefix . 'tutor_enrollments', + array( 'id' => $enrollment_id ) +); + +// INSERT +QueryHelper::insert( $wpdb->prefix . 'tutor_enrollments', $data ); + +// UPDATE +QueryHelper::update( + $wpdb->prefix . 'tutor_enrollments', + array( 'status' => 'completed' ), // data to set + array( 'id' => $enrollment_id ) // WHERE +); + +// DELETE +QueryHelper::delete( + $wpdb->prefix . 'tutor_enrollments', + array( 'id' => $enrollment_id ) +); + +// COUNT +$total = QueryHelper::get_count( + $wpdb->prefix . 'tutor_enrollments', + array( 'course_id' => $course_id ) +); + +// IN clause helper +$clause = QueryHelper::prepare_in_clause( $ids_array ); // returns %d,%d,%d +``` + +### 5.4 `HttpHelper` — HTTP Status Constants + +Always use `HttpHelper` constants for response codes — never hardcode numbers: + +```php +use Tutor\Helpers\HttpHelper; + +HttpHelper::STATUS_OK // 200 +HttpHelper::STATUS_CREATED // 201 +HttpHelper::STATUS_BAD_REQUEST // 400 +HttpHelper::STATUS_UNAUTHORIZED // 401 +HttpHelper::STATUS_FORBIDDEN // 403 +HttpHelper::STATUS_NOT_FOUND // 404 +HttpHelper::STATUS_UNPROCESSABLE_ENTITY // 422 +HttpHelper::STATUS_INTERNAL_SERVER_ERROR // 500 +``` + +### 5.5 `JsonResponse` trait + +Use in any class that sends AJAX or REST responses. Never call `wp_send_json_success/error` directly when this trait is available. + +```php +use Tutor\Traits\JsonResponse; + +class My_Class { + use JsonResponse; + + public function my_ajax_handler() { + // Success + $this->json_response( __( 'Done', 'tutor' ), $data ); + + // Success with custom status + $this->json_response( __( 'Created', 'tutor' ), $data, HttpHelper::STATUS_CREATED ); + + // Error + $this->json_response( + tutor_utils()->error_message( 'nonce' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } +} +``` + +Signature: `json_response( string $message, mixed $data = null, int $status_code = 200 ): void` + +### 5.6 `DateTimeHelper` — Date & Time + +```php +use Tutor\Helpers\DateTimeHelper; + +// Format constants +DateTimeHelper::FORMAT_MYSQL // 'Y-m-d H:i:s' +DateTimeHelper::FORMAT_DATE // 'Y-m-d' + +// Convert GMT datetime to user timezone +DateTimeHelper::get_gmt_to_user_timezone_date( $gmt_datetime_string ); + +// Formatting +date( DateTimeHelper::FORMAT_MYSQL, $timestamp ); +``` + +### 5.7 `ValidationHelper` + +```php +use Tutor\Helpers\ValidationHelper; + +$errors = ValidationHelper::validate( + array( + 'title' => Input::post( 'title', '' ), + 'course_id'=> Input::post( 'course_id', 0, Input::TYPE_INT ), + ), + array( + 'title' => 'required', + 'course_id' => 'required|numeric', + ) +); + +if ( count( $errors ) ) { + $this->json_response( + __( 'Invalid input', 'tutor' ), + $errors, + HttpHelper::STATUS_UNPROCESSABLE_ENTITY + ); +} +``` + +### 5.8 `TutorCache` + +```php +use Tutor\Cache\TutorCache; + +// Get cached value +$data = TutorCache::get( 'cache-key' ); + +// Set cache +TutorCache::set( 'cache-key', $data ); + +// Delete cache +TutorCache::delete( 'cache-key' ); +``` + +--- + +## PART 6 — MODEL REFERENCE + +### CourseModel + +```php +use Tutor\Models\CourseModel; + +// Post status constants +CourseModel::STATUS_PUBLISH +CourseModel::STATUS_PENDING +CourseModel::STATUS_DRAFT +CourseModel::STATUS_FUTURE +CourseModel::STATUS_PRIVATE + +// Methods +CourseModel::get_courses_by_instructor( $user_id, $status, $offset, $per_page, $count_only, $post_type ); +CourseModel::get_post_types( $post ); // check if valid course post type +``` + +### User class + +```php +use TUTOR\User; + +User::STUDENT // 'subscriber' +User::INSTRUCTOR // 'tutor_instructor' +User::ADMIN // 'administrator' + +User::is_admin(); // static — checks if current user is admin +``` + +### Post type constants (via tutor()) + +```php +tutor()->course_post_type // 'courses' +tutor()->lesson_post_type // 'lesson' +// Quiz post type: 'tutor_quiz' +// Topic post type: 'topics' +// Assignment post type: 'tutor_assignments' +``` + +--- + +## PART 7 — PHPDoc DOCUMENTATION STANDARDS + +All classes, methods, properties, constants, hooks, and filters **must** have PHPDoc blocks. + +### Class doc block + +```php +/** + * Brief one-line description. + * + * Optional longer description spanning + * multiple lines if needed. + * + * @package Tutor + * @author Themeum + * @link https://themeum.com + * @since 1.0.0 + */ +``` + +### Method doc block + +```php +/** + * Brief description of what this method does. + * + * Extended description if the method has complex behavior, + * side effects, or important notes for future developers. + * + * @since 1.0.0 + * @since 2.5.0 Added $limit param. + * + * @param int $course_id The course post ID. + * @param int $user_id Optional. WordPress user ID. Default 0 (current user). + * @param int $limit Optional. Max records to return. Default -1 (all). + * @return array|false Array of records on success, false on failure. + */ +public function get_enrollments( int $course_id, int $user_id = 0, int $limit = -1 ) { +``` + +### Property doc block + +```php +/** + * Whether bulk action is enabled on this list page. + * + * @since 1.0.0 + * @var bool + */ +public $bulk_action = true; + +/** + * Cache key for instructor list. + * + * @since 2.0.0 + * @var string + */ +const INSTRUCTOR_LIST_CACHE_KEY = 'tutor-instructors-list'; +``` + +### Hook documentation + +```php +/** + * Fires after a student successfully enrolls in a course. + * + * @since 1.0.0 + * + * @param int $course_id The course post ID. + * @param int $user_id The enrolled user ID. + * @param int $enroll_id The enrollment record ID. + */ +do_action( 'tutor_after_enroll', $course_id, $user_id, $enroll_id ); + +/** + * Filters the course archive query arguments. + * + * @since 1.0.0 + * + * @param array $args WP_Query arguments. + * @return array + */ +$args = apply_filters( 'tutor_course_archive_args', $args ); +``` + +--- + +## PART 8 — SECURITY BEST PRACTICES + +### 8.1 Nonce verification + +**Every AJAX handler must verify a nonce first.** Use the Tutor utility wrapper: + +```php +// ✅ Tutor standard — checks tutor()->nonce_action and dies on failure +tutor_utils()->checking_nonce(); + +// ✅ WP standard when you need a specific action +if ( ! wp_verify_nonce( + sanitize_text_field( wp_unslash( $_POST['_tutor_nonce'] ?? '' ) ), + tutor()->nonce_action +) ) { + $this->json_response( + tutor_utils()->error_message( 'nonce' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); +} +``` + +### 8.2 Capability checks + +**All privileged AJAX/REST handlers must check permissions before processing:** + +```php +// Admin operations +if ( ! current_user_can( 'manage_options' ) ) { + $this->json_response( + tutor_utils()->error_message( 'forbidden' ), + null, + HttpHelper::STATUS_FORBIDDEN + ); +} + +// Course management (admin or instructor of the course) +$is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id ); +if ( ! current_user_can( 'administrator' ) && ! $is_instructor ) { + $this->json_response( + tutor_utils()->error_message( 'forbidden' ), + null, + HttpHelper::STATUS_FORBIDDEN + ); +} + +// Resource management via helper +if ( ! tutor_utils()->can_user_manage( 'course', $course_id ) ) { + wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) ); +} +``` + +### 8.3 Input — always use Input:: class + +```php +// ✅ Always use Input:: — never raw superglobals +$course_id = Input::post( 'course_id', 0, Input::TYPE_INT ); +$title = Input::post( 'title', '' ); +$page = Input::get( 'page', '' ); + +// ❌ Never do this +$course_id = $_POST['course_id']; +$title = $_POST['title']; +``` + +### 8.4 Output escaping + +Follow context-specific escaping. This is always required in templates and views: + +```php +// HTML text +echo esc_html( $title ); +echo esc_html__( 'Enroll Now', 'tutor' ); + +// HTML attributes +echo esc_attr( $class ); +echo '
'; + +// URLs +echo esc_url( get_permalink( $course_id ) ); +echo esc_url( tutor_utils()->get_tutor_dashboard_page_permalink( 'my-courses' ) ); + +// Rich HTML (trusted content only) +echo wp_kses_post( $description ); + +// In templates +?> +

post_title ); ?>

+ + post_title ); ?> + + + + +prepare()` for custom queries: + +```php +// ✅ Use QueryHelper for standard CRUD +$rows = QueryHelper::get_all( $wpdb->prefix . 'tutor_enrollments', array( 'course_id' => $course_id ) ); + +// ✅ Use $wpdb->prepare() for custom queries +$results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}tutor_enrollments WHERE course_id = %d AND status = %s LIMIT %d", + $course_id, + 'completed', + $limit + ) +); + +// ❌ Never interpolate variables into queries +$results = $wpdb->get_results( "SELECT * FROM wp_tutor_enrollments WHERE course_id = $course_id" ); +``` + +### 8.6 Direct file access prevention + +All PHP files must have this check at the top, immediately after the namespace declaration: + +```php +if ( ! defined( 'ABSPATH' ) ) { + exit; +} +``` + +--- + +## PART 9 — AJAX HANDLER PATTERN + +The complete, correct pattern for every Tutor AJAX handler: + +```php +/** + * Handle AJAX action to save lesson progress. + * + * @since 1.0.0 + * @return void + */ +public function ajax_save_lesson_progress() { + // Step 1: Verify nonce (always first) + tutor_utils()->checking_nonce(); + + // Step 2: Verify permissions + if ( ! is_user_logged_in() ) { + $this->json_response( + tutor_utils()->error_message( 'forbidden' ), + null, + HttpHelper::STATUS_FORBIDDEN + ); + } + + // Step 3: Read and validate input via Input:: class + $lesson_id = Input::post( 'lesson_id', 0, Input::TYPE_INT ); + $course_id = Input::post( 'course_id', 0, Input::TYPE_INT ); + + if ( ! $lesson_id || ! $course_id ) { + $this->json_response( + __( 'Invalid input', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + // Step 4: Business logic / data operation + $result = MyModel::save_progress( $lesson_id, get_current_user_id() ); + + if ( ! $result ) { + $this->json_response( + __( 'Could not save progress', 'tutor' ), + null, + HttpHelper::STATUS_INTERNAL_SERVER_ERROR + ); + } + + // Step 5: Fire action hook for extensibility + do_action( 'tutor_lesson_progress_saved', $lesson_id, $course_id, get_current_user_id() ); + + // Step 6: Return success response + $this->json_response( __( 'Progress saved', 'tutor' ) ); +} +``` + +Register handlers in the constructor: + +```php +add_action( 'wp_ajax_tutor_save_lesson_progress', array( $this, 'ajax_save_lesson_progress' ) ); +// For logged-out users: +add_action( 'wp_ajax_nopriv_tutor_save_lesson_progress', array( $this, 'ajax_save_lesson_progress' ) ); +``` + +--- + +## PART 10 — HOOK NAMING CONVENTIONS + +All Tutor hooks are prefixed with `tutor_`. Follow this naming structure: + +| Type | Pattern | Example | +| ----------------- | --------------------------- | ------------------------------------- | +| Action (before) | `tutor_before_{event}` | `tutor_before_enroll` | +| Action (after) | `tutor_after_{event}` | `tutor_after_enroll` | +| Action (AJAX) | `tutor_action_{name}` | `tutor_action_regenerate_tutor_pages` | +| Filter (data) | `tutor_{object}_{property}` | `tutor_course_archive_args` | +| Filter (template) | `tutor_get_template_path` | — | +| Filter (localize) | `tutor_localize_data` | — | + +Dynamic hooks use **interpolation**, not concatenation: + +```php +// ✅ Correct +do_action( "{$post->post_type}_saved", $post->ID ); +apply_filters( "tutor_{$context}_data", $data ); + +// ❌ Incorrect +do_action( $post->post_type . '_saved', $post->ID ); +``` + +--- + +## PART 11 — TEMPLATE & VIEW RULES + +### Templates (`templates/`) + +Templates are rendered on the **frontend**. Strict output-only rules: + +```php +get_option( 'courses_per_page', 10 ); +$paged = Input::get( 'current_page', 1, Input::TYPE_INT ); +$results = CourseModel::get_courses_by_instructor( $current_user_id, 'publish', 0, $per_page ); +?> + +
+ +
+

post_title ); ?>

+ + + +
+ +
+``` + +**Templates must NOT contain:** + +- Database queries (use Models) +- Hook registration +- Business logic / calculations +- Nonce generation (pass via template variables from controller) + +### Views (`views/`) + +Views are rendered in the **WordPress admin**. Same rules as templates — output only. + +--- + +## PART 12 — ASSETS & ENQUEUEING + +Always enqueue via the `Assets` class hooks. Never hardcode ` + + + +``` + +### Theme Switching + +Switch themes by changing the `data-theme` attribute: + +```javascript +// Switch to dark theme +document.documentElement.setAttribute('data-theme', 'dark'); + +// Switch to light theme +document.documentElement.setAttribute('data-theme', 'light'); +``` + +### Font Scaling + +The design system supports font scaling for accessibility. All typography uses `rem` units and scales proportionally: + +```javascript +// Set font scale to 120% (larger text for better accessibility) +TutorCore.utils.setFontScale(120); + +// Set font scale to 80% (smaller text) +TutorCore.utils.setFontScale(80); + +// Reset to default size (100%) +TutorCore.utils.resetFontScale(); + +// Get current font scale percentage +const currentScale = TutorCore.utils.getFontScale(); // Returns 80, 90, 100, 110, or 120 +``` + +You can also use CSS classes directly: + +```html + + + +
+

This heading will be smaller

+

This paragraph will also be smaller

+
+ +``` + +**Available font scales:** 80%, 90%, 100% (default), 110%, 120% + +### RTL Support + +Enable RTL layout by setting the `dir` attribute: + +```html + +``` + +## Components + +### Buttons + +```html + + + + + + + + + + + + + + + + +``` + +### Cards + +```html +
+
+

Card Title

+

Card subtitle

+
+
+

Card content goes here...

+
+ +
+``` + +### Forms + +```html +
+ + +

We'll never share your email.

+
+ +
+ + +
+``` + +## Alpine.js Components + +The TutorCore class provides factory methods for creating Alpine.js component data objects with built-in RTL support, accessibility features, and TypeScript definitions. + +### Dropdown + +**Configuration Options:** + +- `placement`: Position relative to trigger ('bottom-start', 'bottom-end', 'top-start', 'top-end') +- `offset`: Distance from trigger element (default: 4) +- `closeOnClickOutside`: Close when clicking outside (default: true) + +```html +
+ + +
+``` + +### Modal + +**Configuration Options:** + +- `closable`: Allow closing with ESC key or backdrop click (default: true) +- `backdrop`: Show backdrop overlay (default: true) +- `keyboard`: Enable ESC key to close (default: true) + +```html +
+ + +
+
+
+
+

Modal Title

+ +
+
+

Modal content goes here...

+
+ +
+
+
+``` + +### Tabs + +**Configuration Options:** + +- `defaultTab`: Initial active tab index (default: 0) + +```html +
+
+ + + +
+
+
Overview content...
+
Details content...
+
Reviews content...
+
+
+``` + +### Accordion + +**Configuration Options:** + +- `multiple`: Allow multiple panels open (default: false) +- `defaultOpen`: Array of initially open panel indices (default: []) + +```html +
+
+ +
+

Panel 1 content...

+
+
+
+ +
+

Panel 2 content...

+
+
+
+``` + +### Toast Notifications + +**Methods:** + +- `show(message, config)`: Display a toast notification +- `success(message)`: Show success toast +- `error(message)`: Show error toast +- `warning(message)`: Show warning toast +- `info(message)`: Show info toast +- `remove(id)`: Remove specific toast +- `clear()`: Remove all toasts + +```html +
+ + + + + +
+ +
+
+``` + +### Tooltip + +**Configuration Options:** + +- `placement`: Tooltip position ('top', 'bottom', 'left', 'right') +- `trigger`: Trigger event ('hover', 'focus', 'click') +- `delay`: Show/hide delay in milliseconds + +```html +
+ +
This is a helpful tooltip!
+
+``` + +### Popover + +**Configuration Options:** + +- `placement`: Popover position ('top', 'bottom', 'left', 'right') +- `trigger`: Trigger event ('click', 'hover') +- `closeOnClickOutside`: Close when clicking outside (default: true) + +```html +
+ +
+
+
+

Popover Title

+

This is popover content with more detailed information.

+ +
+
+
+``` + +### Sidebar + +**Configuration Options:** + +- `collapsed`: Initial collapsed state (default: false) +- `breakpoint`: Breakpoint for responsive behavior ('mobile', 'tablet', 'desktop') + +```html +
+ + +
+ +
+

Main content area

+
+
+
+``` + +### Form Validation + +**Configuration Options:** + +- `rules`: Validation rules object +- `messages`: Custom error messages +- `validateOnInput`: Validate on input change (default: true) + +```html +
+
+
+ + +

+
+ +
+ + +

+
+ + +
+
+``` + +## Utility Classes + +### Spacing + +```html + +
Margin on all sides
+
Margin top
+
Horizontal margin
+ + +
Padding on all sides
+
Padding top
+
Horizontal padding
+``` + +### Layout + +```html + +
Centered flex container
+
Space between flex container
+
Flex column
+ + +
Grid container
+ + +
Responsive container
+``` + +### Typography + +```html + +

Heading 1

+

Heading 2

+

Paragraph 1

+

Paragraph 2

+ + + + Small text with medium weight and primary color + + + +

Paragraph 2 with medium font weight

+ + +Large text using H3 size +Small text using P2 size + + +Regular weight (400) +Medium weight (500) +Semi bold weight (600) +Bold weight (700) +``` + +### Colors + +```html + +

Primary text

+

Secondary text

+

Disabled text

+

Brand text

+

Success text

+

Warning text

+

Error text

+ + +
Base surface
+
Level 1 surface
+
Level 2 surface
+ + +
Brand 100
+
Brand 600
+
Brand 950
+ + +
Success light
+
Warning medium
+
Error primary
+``` + +## CSS Classes Reference + +### Button Classes + +```css +/* Base button class */ +.tutor-btn + +/* Button variants */ +.tutor-btn--primary +.tutor-btn--secondary +.tutor-btn--outline +.tutor-btn--ghost + +/* Button sizes */ +.tutor-btn--small +.tutor-btn--medium +.tutor-btn--large + +/* Button states */ +.tutor-btn:disabled +.tutor-btn--loading +``` + +### Card Classes + +```css +/* Base card class */ +.tutor-card + +/* Card sections */ +.tutor-card__header +.tutor-card__body +.tutor-card__footer +.tutor-card__title +.tutor-card__subtitle + +/* Card variants */ +.tutor-card-elevated +.tutor-card-outlined +.tutor-card-interactive +``` + +### Form Classes + +```css +/* Form elements */ +.tutor-form-group +.tutor-label +.tutor-input +.tutor-textarea +.tutor-select +.tutor-checkbox +.tutor-radio + +/* Form states */ +.tutor-input--error +.tutor-input--success +.tutor-input--disabled + +/* Form text */ +.tutor-help-text +.tutor-error-text +.tutor-success-text +``` + +### Component Classes + +```css +/* Dropdown */ +.tutor-dropdown +.tutor-dropdown__menu +.tutor-dropdown__item + +/* Modal */ +.tutor-modal +.tutor-modal__backdrop +.tutor-modal__content +.tutor-modal__header +.tutor-modal__body +.tutor-modal__footer +.tutor-modal__close + +/* Tabs */ +.tutor-tabs +.tutor-tabs__nav +.tutor-tab +.tutor-tab.active +.tutor-tabs__content +.tutor-tab-panel + +/* Accordion */ +.tutor-accordion +.tutor-accordion__item +.tutor-accordion__trigger +.tutor-accordion__content + +/* Toast */ +.tutor-toast-container +.tutor-toast +.tutor-toast--success +.tutor-toast--error +.tutor-toast--warning +.tutor-toast--info +.tutor-toast__close + +/* Tooltip */ +.tutor-tooltip +.tutor-tooltip--top +.tutor-tooltip--bottom +.tutor-tooltip--left +.tutor-tooltip--right + +/* Popover */ +.tutor-popover +.tutor-popover__arrow +.tutor-popover__content + +/* Sidebar */ +.tutor-sidebar +.tutor-sidebar.collapsed +.tutor-sidebar__nav +.tutor-sidebar__item +``` + +### Spacing Classes + +```css +/* Margin classes (0-21 based on Figma tokens) */ +.tutor-m-{size} /* All sides */ +.tutor-mt-{size} /* Top */ +.tutor-me-{size} /* End (RTL-aware right/left) */ +.tutor-mb-{size} /* Bottom */ +.tutor-ms-{size} /* Start (RTL-aware left/right) */ +.tutor-mx-{size} /* Horizontal */ +.tutor-my-{size} /* Vertical */ + +/* Padding classes (0-21 based on Figma tokens) */ +.tutor-p-{size} /* All sides */ +.tutor-pt-{size} /* Top */ +.tutor-pe-{size} /* End (RTL-aware right/left) */ +.tutor-pb-{size} /* Bottom */ +.tutor-ps-{size} /* Start (RTL-aware left/right) */ +.tutor-px-{size} /* Horizontal */ +.tutor-py-{size} /* Vertical */ + +/* Available sizes: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 */ +/* Corresponding to: 0px, 2px, 4px, 6px, 8px, 12px, 16px, 20px, 24px, 28px, 32px, 36px, 40px, 44px, 48px, 56px, 64px, 80px, 96px, 112px, 128px, 200px */ +``` + +### Layout Classes + +```css +/* Container */ +.tutor-container + +/* Flexbox */ +.tutor-flex +.tutor-flex-center +.tutor-flex-between +.tutor-flex-around +.tutor-flex-column +.tutor-flex-wrap + +/* Grid */ +.tutor-grid + +/* Display */ +.tutor-block +.tutor-inline +.tutor-inline-block +.tutor-hidden + +/* Responsive utilities */ +.tutor-md-flex +.tutor-md-grid +.tutor-md-hidden +.tutor-lg-flex +.tutor-lg-grid +.tutor-lg-hidden +``` + +### Typography Classes + +```css +/* Headings */ +.tutor-h1 +.tutor-h2 +.tutor-h3 +.tutor-h4 +.tutor-h5 + +/* Paragraphs */ +.tutor-p1 +.tutor-p2 +.tutor-p3 + +/* Text utilities */ +.tutor-text-left +.tutor-text-center +.tutor-text-right +.tutor-text-justify + +/* Font weights */ +.tutor-font-light +.tutor-font-normal +.tutor-font-medium +.tutor-font-semibold +.tutor-font-bold +``` + +## SASS Mixins + +If you're using SASS, you can use the provided mixins for custom components. All mixins are RTL-aware and use design tokens: + +### Button Mixins + +```scss +@import 'tutor-design-system/scss/main'; + +.my-button { + @include tutor-button-base; + @include tutor-button-variant(primary); // primary, secondary, outline, ghost + @include tutor-button-size(large); // small, medium, large +} + +.my-button-group { + @include tutor-button-group(horizontal); // horizontal, vertical +} + +.loading-button { + @include tutor-button-loading; +} + +// RTL-aware button with icon +.icon-button { + @include tutor-button-base; + @include tutor-button-variant(primary); + + .icon { + @include margin-end(8px); // Automatically adapts to RTL + } +} +``` + +### Card Mixins + +```scss +.my-card { + @include tutor-card-base; + @include tutor-card-elevation(2); // 0-4 + @include tutor-card-padding(large); // small, medium, large + @include tutor-card-radius(large); // small, medium, large +} + +.interactive-card { + @include tutor-card-interactive; +} +``` + +### Form Mixins + +```scss +.my-input { + @include tutor-input-base; + @include tutor-input-size(large); // small, medium, large + @include tutor-input-validation(error); // error, success, warning +} + +.my-textarea { + @include tutor-textarea-base; + @include tutor-textarea-resize(vertical); // none, both, horizontal, vertical +} + +.my-select { + @include tutor-select-base; + @include tutor-select-arrow; +} +``` + +### Layout Mixins + +```scss +.my-container { + @include tutor-container; + @include tutor-container-size(large); // small, medium, large, full +} + +.my-flex { + @include tutor-flex; + @include tutor-flex-center; + @include tutor-flex-gap(16px); +} + +.my-grid { + @include tutor-grid; + @include tutor-grid-columns(3); // Number of columns + @include tutor-grid-gap(24px); +} +``` + +### RTL Mixins + +```scss +.my-component { + // Directional margins (automatically adapts to RTL) + @include margin-start(16px); + @include margin-end(8px); + + // Directional padding (automatically adapts to RTL) + @include padding-start(12px); + @include padding-end(12px); + + // Directional borders (automatically adapts to RTL) + @include border-start(1px solid var(--tutor-color-border-primary)); + @include border-radius-start(8px); + + // Text alignment (automatically adapts to RTL) + @include text-align-start; + + // Positioning (automatically adapts to RTL) + @include inset-inline-start(0); + + // Transform for RTL + @include rtl-transform(translateX(-100%)); + + // Icon positioning (automatically adapts to RTL) + .icon { + @include icon-start(8px); + } +} + +// RTL-aware utility mixins +.rtl-aware-component { + @include rtl-property(margin-left, margin-right, 16px); + @include rtl-value(text-align, left, right); + @include rtl-flip-horizontal; // Flips component horizontally in RTL +} +``` + +### Utility Mixins + +```scss +.visually-hidden { + @include tutor-visually-hidden; +} + +.truncate-text { + @include tutor-truncate; +} + +.focus-ring { + @include tutor-focus-ring; +} + +// Responsive mixins +.responsive-component { + @include tutor-respond-to(md) { + // Styles for tablet and up + } + + @include tutor-respond-to(lg) { + // Styles for desktop and up + } +} +``` + +### Animation Mixins + +```scss +.fade-in { + @include tutor-fade-in(0.3s); +} + +.slide-up { + @include tutor-slide-up(0.2s); +} + +.bounce { + @include tutor-bounce; +} + +.spin { + @include tutor-spin(1s); +} +``` + +## TutorCore API Reference + +### Component Factory Methods + +All component methods return Alpine.js data objects that can be used with `x-data`: + +#### `TutorCore.dropdown(config?: DropdownConfig)` + +Creates a dropdown component with RTL-aware positioning and keyboard navigation. + +**Config Options:** + +```typescript +interface DropdownConfig { + placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; + offset?: number; + closeOnClickOutside?: boolean; +} +``` + +**Returned Methods:** + +- `toggle()`: Toggle dropdown visibility +- `open()`: Show dropdown +- `close()`: Hide dropdown +- `handleKeydown(event)`: Handle keyboard navigation + +#### `TutorCore.modal(config?: ModalConfig)` + +Creates a modal with focus management and accessibility features. + +**Config Options:** + +```typescript +interface ModalConfig { + closable?: boolean; + backdrop?: boolean; + keyboard?: boolean; +} +``` + +**Returned Methods:** + +- `show()`: Show modal +- `hide()`: Hide modal +- `handleKeydown(event)`: Handle ESC key + +#### `TutorCore.toast()` + +Creates a toast notification system with stacking and auto-dismiss. + +**Returned Methods:** + +- `show(message: string, config?: ToastConfig)`: Show toast +- `success(message: string)`: Show success toast +- `error(message: string)`: Show error toast +- `warning(message: string)`: Show warning toast +- `info(message: string)`: Show info toast +- `remove(id: number)`: Remove specific toast +- `clear()`: Remove all toasts + +#### `TutorCore.tabs(config?: TabsConfig)` + +Creates a tab component with keyboard navigation and ARIA support. + +**Config Options:** + +```typescript +interface TabsConfig { + defaultTab?: number; +} +``` + +**Returned Methods:** + +- `setTab(index: number)`: Set active tab +- `isActive(index: number)`: Check if tab is active +- `nextTab()`: Navigate to next tab +- `prevTab()`: Navigate to previous tab + +#### `TutorCore.accordion(config?: AccordionConfig)` + +Creates an accordion with single or multiple panel support. + +**Config Options:** + +```typescript +interface AccordionConfig { + multiple?: boolean; + defaultOpen?: number[]; +} +``` + +**Returned Methods:** + +- `toggle(index: number)`: Toggle panel +- `open(index: number)`: Open panel +- `close(index: number)`: Close panel +- `isOpen(index: number)`: Check if panel is open + +### Utility Methods + +#### `TutorCore.utils.isRTL(): boolean` + +Check if the current document direction is RTL. + +#### `TutorCore.utils.getDirection(): 'ltr' | 'rtl'` + +Get the current document direction. + +#### `TutorCore.utils.adaptPlacement(placement: string): string` + +Adapt placement strings for RTL layouts. + +#### `TutorCore.utils.generateId(): string` + +Generate a unique ID for components. + +#### `TutorCore.utils.debounce(func: T, wait: number): T` + +Debounce function calls. + +#### `TutorCore.utils.throttle(func: T, limit: number): T` + +Throttle function calls. + +#### `TutorCore.utils.getBreakpoint(): string` + +Get current breakpoint ('mobile', 'tablet', 'desktop'). + +#### `TutorCore.utils.isMobile(): boolean` + +Check if current viewport is mobile. + +#### `TutorCore.utils.isTablet(): boolean` + +Check if current viewport is tablet. + +#### `TutorCore.utils.isDesktop(): boolean` + +Check if current viewport is desktop. + +## TypeScript Support + +The library includes full TypeScript definitions: + +```typescript +import { TutorCore } from 'tutor-design-system'; + +// Use component methods with type safety +const dropdownData = TutorCore.dropdown({ + placement: 'bottom-start', + closeOnClickOutside: true, +}); + +const modalData = TutorCore.modal({ + keyboard: true, + backdrop: true, +}); + +// Access utility methods +const isRTL = TutorCore.utils.isRTL(); +const uniqueId = TutorCore.utils.generateId(); + +// Use with Alpine.js +document.addEventListener('alpine:init', () => { + Alpine.data('myDropdown', () => TutorCore.dropdown()); +}); +``` + +## Browser Support + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +## For Developers + +### Architecture Overview + +The Tutor Design System follows a hybrid approach combining the best of both SASS variables and CSS custom properties: + +- **SASS Variables**: Used for static values that don't change between themes (typography, spacing, radius) +- **CSS Custom Properties**: Used for dynamic values that change between themes (colors, surfaces) +- **RTL Mixins**: Provide directional-aware styling for international support +- **Component Patterns**: Follow BEM naming convention with design system integration + +### When to Use SASS Variables vs CSS Custom Properties + +**✅ Use SASS Variables for:** + +```scss +// Static values that don't change between themes +font-family: $tutor-font-family-body; +font-size: $tutor-font-size-p1; +line-height: $tutor-line-height-p1; +font-weight: $tutor-font-weight-medium; +padding: $tutor-spacing-4; +border-radius: $tutor-radius-md; +``` + +**✅ Use CSS Custom Properties for:** + +```scss +// Dynamic values that change between themes +color: var(--tutor-text-primary); +background-color: var(--tutor-surface-l1); +border-color: var(--tutor-border-idle); +``` + +**✅ Best Practice - Mixed Approach:** + +```scss +.my-component { + // Static properties - SASS variables + font-family: $tutor-font-family-body; + font-size: $tutor-font-size-p1; + padding: $tutor-spacing-4; + border-radius: $tutor-radius-md; + + // Dynamic properties - CSS custom properties + color: var(--tutor-text-primary); + background: var(--tutor-surface-l1); + border: 1px solid var(--tutor-border-idle); +} +``` + +### Building Custom Components + +Follow these patterns when creating custom components: + +#### 1. Component Structure Template + +```scss +.my-component { + // Layout & Structure (SASS variables) + display: flex; + padding: $tutor-spacing-4; + border-radius: $tutor-radius-lg; + font-family: $tutor-font-family-body; + font-size: $tutor-font-size-p1; + + // Appearance (CSS custom properties) + background: var(--tutor-surface-l1); + border: 1px solid var(--tutor-border-idle); + color: var(--tutor-text-primary); + + // RTL Support + @include text-align-start(); + + // States + &:hover { + border-color: var(--tutor-border-brand); + } + + // Variants + &--primary { + background: var(--tutor-actions-brand-primary); + color: var(--tutor-text-primary-inverse); + } + + // Elements + &__header { + @include margin-end($tutor-spacing-4); + } +} +``` + +#### 2. RTL-Aware Development + +Always use RTL mixins for directional properties: + +```scss +.my-component { + // ✅ Good - RTL aware + @include margin-start($tutor-spacing-4); + @include padding-end($tutor-spacing-2); + @include border-start(2px solid var(--tutor-border-brand)); + @include text-align-start(); + + // ❌ Bad - Not RTL aware + margin-left: $tutor-spacing-4; + padding-right: $tutor-spacing-2; + border-left: 2px solid var(--tutor-border-brand); + text-align: left; +} +``` + +#### 3. Component Development Best Practices + +**✅ Do's:** + +- Use BEM naming convention +- Prefix components with your namespace +- Mix SASS variables and CSS custom properties appropriately +- Use RTL mixins for directional properties +- Test in both light and dark themes +- Test in both LTR and RTL directions +- Use design system tokens instead of hardcoded values +- Include proper accessibility attributes + +**❌ Don'ts:** + +- Don't use hardcoded colors, sizes, or spacing +- Don't use left/right properties directly +- Don't override design system components directly +- Don't use CSS custom properties for static values +- Don't use SASS variables for theme-dependent colors +- Don't forget RTL testing + +### Common Patterns + +#### Alert Component Pattern + +```scss +.custom-alert { + padding: $tutor-spacing-4 $tutor-spacing-6; + border-radius: $tutor-radius-md; + font-size: $tutor-font-size-p2; + @include border-start(4px solid); + + &--success { + background: var(--tutor-actions-success-tertiary); + border-color: var(--tutor-actions-success-primary); + color: var(--tutor-text-success); + } + + &--error { + background: var(--tutor-actions-critical-secondary); + border-color: var(--tutor-actions-critical-primary); + color: var(--tutor-text-critical); + } +} +``` + +#### Card Component Pattern + +```scss +.custom-card { + background: var(--tutor-surface-l1); + border: 1px solid var(--tutor-border-idle); + border-radius: $tutor-radius-xl; + padding: $tutor-spacing-6; + transition: all 0.2s ease; + + &:hover { + border-color: var(--tutor-border-brand); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } + + &__header { + margin-bottom: $tutor-spacing-4; + @include padding-bottom($tutor-spacing-4); + border-bottom: 1px solid var(--tutor-border-idle); + } + + &__title { + font-size: $tutor-font-size-h4; + font-weight: $tutor-font-weight-semi-strong; + color: var(--tutor-text-primary); + margin: 0; + } +} +``` + +## Examples + +The library includes comprehensive example files for developers: + +- **`examples/basic-usage.html`** - Basic setup and component usage +- **`examples/alpine-components.html`** - All Alpine.js components with examples +- **`examples/rtl-support.html`** - RTL language support demonstration +- **`examples/font-scaling.html`** - Font scaling and accessibility features +- **`examples/sass-development.html`** - SASS development guide with mixins and tokens +- **`examples/design-tokens.html`** - Visual guide to all design tokens +- **`examples/custom-components.html`** - Building custom components following design system patterns +- **`examples/tutor-design-system-components.html`** - Complete TutorCore API examples + +To view the examples: + +```bash +# Serve the examples directory +npx serve examples/ + +# Or open directly in browser +open examples/basic-usage.html +``` + +## Development + +### Building from Source + +```bash +# Install dependencies +npm install + +# Build CSS and JS files +npm run build + +# Watch for changes during development +npm run dev + +# Run build script with file size summary +node build.js +``` + +### File Structure + +``` +assets/core/ +├── scss/ +│ ├── tokens/ # Design tokens based on Figma specifications +│ │ ├── _colors.scss # Complete color scales (Brand, Gray, Success, Warning, Error, Yellow, Exception) +│ │ ├── _typography.scss # Typography scale (H1-H5, P1-P3) with line heights +│ │ ├── _spacing.scss # Spacing scale (0-21: 0px to 200px) +│ │ ├── _radius.scss # Border radius scale (none to full) +│ │ └── _breakpoints.scss # Responsive breakpoints +│ ├── themes/ # Theme variants with semantic token mappings +│ │ ├── _light.scss # Light theme with Figma surface and text colors +│ │ └── _dark.scss # Dark theme with proper contrast ratios +│ ├── mixins/ # SASS mixins with RTL support +│ │ ├── _buttons.scss # Button variants and states +│ │ ├── _cards.scss # Card layouts and elevation +│ │ ├── _forms.scss # Form elements and validation +│ │ ├── _layout.scss # Flexbox, grid, and positioning +│ │ ├── _utilities.scss # Common utility patterns +│ │ └── _rtl.scss # RTL-aware directional mixins +│ ├── components/ # Component styles with RTL support +│ │ ├── _button.scss # All button variants and sizes +│ │ ├── _card.scss # Flexible card layouts +│ │ ├── _form.scss # Form styling and validation states +│ │ ├── _navigation.scss # Tabs, breadcrumbs, pagination +│ │ ├── _progress.scss # Progress bars and indicators +│ │ ├── _dropdown.scss # Dropdown with RTL positioning +│ │ ├── _modal.scss # Modal with backdrop and responsive layouts +│ │ ├── _tabs.scss # Tab component with transitions +│ │ ├── _accordion.scss # Accordion with animations +│ │ ├── _toast.scss # Toast notifications with stacking +│ │ ├── _tooltip.scss # Tooltip with RTL positioning +│ │ ├── _popover.scss # Popover with arrow positioning +│ │ └── _sidebar.scss # Sidebar with collapse states +│ ├── utilities/ # RTL-aware utility classes +│ │ ├── _colors.scss # Color utilities for all themes +│ │ ├── _layout.scss # Flexbox, grid, display utilities +│ │ ├── _spacing.scss # RTL-aware margin and padding +│ │ ├── _typography.scss # Typography and text utilities +│ │ ├── _borders.scss # Border utilities with smart defaults +│ │ └── _sizing.scss # Width, height, and sizing utilities +│ └── main.scss # Main entry point +├── ts/ +│ ├── types/ # TypeScript definitions (no 'any' types) +│ │ ├── components.ts # Component interfaces and types +│ │ └── alpine.ts # Alpine.js integration types +│ ├── components/ # Component logic with proper typing +│ │ ├── dropdown.ts # Dropdown with RTL positioning +│ │ ├── modal.ts # Modal with focus management +│ │ ├── tabs.ts # Tabs with keyboard navigation +│ │ ├── accordion.ts # Accordion with animation support +│ │ ├── toast.ts # Toast system with stacking +│ │ ├── tooltip.ts # Tooltip with RTL positioning +│ │ ├── popover.ts # Popover with collision detection +│ │ ├── sidebar.ts # Sidebar with responsive behavior +│ │ └── form-validation.ts # Form validation with custom rules +│ ├── utils/ # Utility functions +│ │ └── rtl-detection.ts # RTL detection and utilities +│ └── index.ts # Main TutorCore class export +├── examples/ # Example HTML files +│ ├── basic-usage.html +│ ├── alpine-components.html +│ ├── rtl-support.html +│ └── tutor-design-system-components.html +├── dist/ # Compiled files +│ ├── tutor-design-system.css # Single CSS file with all themes +│ ├── tutor-design-system.min.css # Minified CSS +│ ├── tutor-design-system.js # Single JS file with TutorCore +│ ├── tutor-design-system.min.js # Minified JS +│ └── tutor-design-system.d.ts # TypeScript declarations +├── package.json +├── tsconfig.json +├── rollup.config.js +├── build.js +├── CONTRIBUTING.md # Development guidelines +└── README.md +``` + +## Troubleshooting + +### Common Issues + +**CSS not loading properly:** + +- Ensure the CSS file is included before any custom styles +- Check that the `data-theme` attribute is set on the `` element +- Verify the file path is correct + +**Alpine.js components not working:** + +- Make sure Alpine.js is loaded before the design system JS +- Check that the `x-data` directive uses the correct TutorCore method +- Ensure the component is properly initialized + +**RTL layout issues:** + +- Set the `dir="rtl"` attribute on the `` element +- Check that RTL-specific CSS classes are being applied +- Verify that directional properties are working correctly + +**TypeScript errors:** + +- Ensure the TypeScript declaration file (`index.d.ts`) is included +- Check that the import path is correct +- Verify that the TypeScript version is compatible + +**Build errors:** + +- Run `npm install` to ensure all dependencies are installed +- Check that Node.js version is 16 or higher +- Clear the `dist/` directory and rebuild + +### Performance Tips + +- Use the minified versions (`*.min.css` and `*.min.js`) in production +- Enable gzip compression on your server for better file sizes +- Consider loading the CSS inline for critical above-the-fold content +- Use the `preload` link relation for faster resource loading: + +```html + + +``` + +- The design system uses CSS custom properties for theme switching, which is more performant than class-based theming +- All utility classes are RTL-aware by default, eliminating the need for separate RTL stylesheets +- Single file distribution reduces HTTP requests and improves loading performance + +### Browser Compatibility + +If you need to support older browsers: + +- Include CSS custom property polyfills for IE11 +- Use Alpine.js v2 for better IE11 support +- Consider using PostCSS autoprefixer for vendor prefixes +- Test thoroughly in your target browsers + +## License + +MIT License - see LICENSE file for details. diff --git a/assets/core/docs/README.md b/assets/core/docs/README.md new file mode 100644 index 0000000000..73780c12a7 --- /dev/null +++ b/assets/core/docs/README.md @@ -0,0 +1,291 @@ +# Tutor Design System Documentation + +A comprehensive, interactive documentation site for the Tutor Design System with live examples, design token references, and developer guides. + +## 📁 Documentation Structure + +``` +docs/ +├── index.html # Main documentation hub with navigation +├── components.html # Interactive component showcase +├── design-tokens.html # Complete design token reference +├── assets/ +│ ├── docs.css # Documentation-specific styles +│ └── docs.js # Documentation functionality +└── README.md # This file +``` + +## 🚀 Features + +### Interactive Documentation Hub + +- **Responsive Navigation**: Collapsible sidebar with search functionality +- **Theme Switching**: Live theme toggle between light and dark modes +- **RTL Support**: Real-time RTL/LTR direction switching +- **Font Scaling**: Accessibility controls for font size adjustment +- **Mobile Optimized**: Fully responsive design for all devices + +### Component Showcase + +- **Live Examples**: Interactive demonstrations of all components +- **Code Snippets**: Copy-to-clipboard code examples +- **Alpine.js Integration**: Working examples of Alpine.js components +- **State Variations**: Different component states and variants +- **Real-time Theming**: Components update with theme changes + +### Design Token Reference + +- **Complete Token System**: All design tokens with visual examples +- **Color Scales**: Interactive color palette with hex values +- **Typography System**: Font sizes, weights, and line heights +- **Spacing Scale**: Visual spacing demonstrations +- **Semantic Mappings**: CSS custom properties and their values +- **Copy Functionality**: One-click copying of token values + +## 🎨 Design System Coverage + +### Components Documented + +- **Buttons**: All variants, sizes, and states +- **Cards**: Basic, elevated, and interactive cards +- **Forms**: Input fields, textareas, validation states +- **Alpine.js Components**: Dropdown, modal, tabs, toast, accordion +- **Typography**: Complete heading and paragraph system +- **Utility Classes**: Spacing, colors, layout utilities + +### Design Tokens Covered + +- **Color System**: Brand, gray, success, warning, error scales +- **Semantic Colors**: Text, surface, action, border mappings +- **Typography**: Font sizes, line heights, weights +- **Spacing**: 22-step spacing scale (0-21) +- **Border Radius**: Complete radius scale +- **Breakpoints**: Responsive design breakpoints + +## 🛠 Technical Implementation + +### Architecture + +- **Alpine.js**: Reactive components and state management +- **CSS Custom Properties**: Theme-aware styling +- **SASS Variables**: Static design tokens +- **Responsive Design**: Mobile-first approach +- **Accessibility**: WCAG compliant with proper focus management + +### Key Features + +- **Search Functionality**: Debounced search across all documentation +- **Code Copying**: Clipboard API with fallback support +- **Theme Persistence**: LocalStorage for user preferences +- **Performance Optimized**: Lazy loading and efficient rendering +- **Cross-browser Compatible**: Modern browser support + +## 📖 Usage Guide + +### Getting Started + +1. Open `index.html` in a web browser +2. Navigate through sections using the sidebar +3. Use the header controls to test different themes and directions +4. Copy code examples directly from the documentation + +### Navigation + +- **Sidebar**: Click any section to navigate +- **Search**: Type to filter navigation items +- **Mobile**: Use hamburger menu on mobile devices +- **Breadcrumbs**: Current section shown in header + +### Interactive Features + +- **Theme Toggle**: Switch between light and dark themes +- **RTL Toggle**: Test right-to-left language support +- **Font Scaling**: Adjust font size for accessibility testing +- **Code Copying**: Click "Copy" buttons to copy code snippets + +## 🎯 Developer Benefits + +### For Component Development + +- **Visual Reference**: See all components in one place +- **Code Examples**: Ready-to-use HTML snippets +- **State Testing**: Test different component states +- **Theme Compatibility**: Verify components work in all themes + +### For Design Token Usage + +- **Token Discovery**: Find the right token for any use case +- **Visual Context**: See how tokens look in practice +- **Copy Values**: Quickly copy token names or values +- **Semantic Understanding**: Learn the token architecture + +### For Integration + +- **Setup Guide**: Complete installation instructions +- **Best Practices**: Recommended usage patterns +- **Architecture Guide**: Understanding the token system +- **Troubleshooting**: Common issues and solutions + +## 🔧 Customization + +### Adding New Components + +1. Add component examples to `components.html` +2. Include code snippets with copy functionality +3. Update navigation in `docs.js` +4. Add any component-specific styles + +### Adding New Tokens + +1. Update token data in `design-tokens.html` +2. Add visual examples and descriptions +3. Include SASS variable and CSS custom property mappings +4. Update the token architecture documentation + +### Styling Customization + +- Modify `assets/docs.css` for visual changes +- Use existing design system tokens for consistency +- Maintain responsive design principles +- Test in both light and dark themes + +## 📱 Responsive Behavior + +### Mobile (< 768px) + +- Collapsible sidebar with overlay +- Stacked navigation controls +- Touch-friendly interactions +- Optimized typography scaling + +### Tablet (768px - 1023px) + +- Persistent sidebar +- Responsive grid layouts +- Balanced content spacing +- Touch and mouse support + +### Desktop (1024px+) + +- Full sidebar navigation +- Multi-column layouts +- Hover interactions +- Keyboard navigation support + +## ♿ Accessibility Features + +### Keyboard Navigation + +- Tab navigation through all interactive elements +- Escape key to close modals and dropdowns +- Arrow keys for component navigation +- Enter/Space for activation + +### Screen Reader Support + +- Semantic HTML structure +- ARIA labels and descriptions +- Focus management +- Proper heading hierarchy + +### Visual Accessibility + +- High contrast ratios in all themes +- Font scaling support (80% - 120%) +- Clear focus indicators +- Sufficient color contrast + +## 🚀 Performance Optimizations + +### Loading Performance + +- Minimal external dependencies +- Optimized CSS and JavaScript +- Efficient Alpine.js components +- Lazy loading where appropriate + +### Runtime Performance + +- Debounced search functionality +- Efficient DOM updates +- Minimal re-renders +- Optimized animations + +### Memory Management + +- Proper event cleanup +- Efficient data structures +- Minimal memory leaks +- Garbage collection friendly + +## 🔄 Maintenance + +### Regular Updates + +- Keep component examples current +- Update design token values +- Maintain code snippet accuracy +- Test across browsers regularly + +### Content Management + +- Review documentation completeness +- Update screenshots and examples +- Verify all links and references +- Maintain consistent terminology + +### Technical Maintenance + +- Update dependencies as needed +- Monitor performance metrics +- Fix accessibility issues +- Optimize for new browsers + +## 📊 Browser Support + +### Fully Supported + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +### Graceful Degradation + +- Older browsers receive basic functionality +- Progressive enhancement approach +- Fallbacks for modern features +- Core content always accessible + +## 🤝 Contributing + +### Documentation Improvements + +1. Identify areas for improvement +2. Create clear, concise content +3. Include visual examples +4. Test across devices and browsers + +### Code Examples + +1. Ensure examples are complete and working +2. Include proper HTML structure +3. Use design system tokens consistently +4. Provide copy-friendly code snippets + +### Design Token Updates + +1. Maintain consistency with Figma designs +2. Update both SASS and CSS custom properties +3. Include visual examples +4. Document semantic mappings + +## 📄 License + +This documentation is part of the Tutor Design System and follows the same licensing terms as the main project. + +--- + +**Built with ❤️ for the Tutor LMS community** + +For questions, issues, or contributions, please refer to the main Tutor Design System repository. diff --git a/assets/core/docs/assets/docs.css b/assets/core/docs/assets/docs.css new file mode 100644 index 0000000000..9e1e3aba4f --- /dev/null +++ b/assets/core/docs/assets/docs.css @@ -0,0 +1,774 @@ +/* Documentation Styles */ +:root { + --docs-sidebar-width: 280px; + --docs-header-height: 64px; + --docs-font-mono: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + --docs-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + font-family: var(--docs-font-sans); + margin: 0; + padding: 0; + background: var(--tutor-surface-base); + color: var(--tutor-text-primary); + line-height: 1.6; +} + +/* Layout */ +.docs-layout { + display: flex; + min-height: 100vh; + position: relative; +} + +/* Mobile Overlay */ +.docs-mobile-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: none; +} + +@media (max-width: 768px) { + .docs-mobile-overlay { + display: block; + } +} + +/* Sidebar */ +.docs-sidebar { + width: var(--docs-sidebar-width); + background: var(--tutor-surface-l1); + border-right: 1px solid var(--tutor-border-idle); + position: fixed; + top: 0; + left: 0; + height: 100vh; + overflow-y: auto; + z-index: 1000; + display: flex; + flex-direction: column; +} + +[dir="rtl"] .docs-sidebar { + left: auto; + right: 0; + border-right: none; + border-left: 1px solid var(--tutor-border-idle); +} + +@media (max-width: 768px) { + .docs-sidebar { + transform: translateX(-100%); + transition: transform 0.3s ease; + } + + [dir="rtl"] .docs-sidebar { + transform: translateX(100%); + } + + .docs-sidebar--open { + transform: translateX(0); + } +} + +/* Sidebar Header */ +.docs-sidebar__header { + padding: 1.5rem; + border-bottom: 1px solid var(--tutor-border-idle); + display: flex; + align-items: center; + justify-content: space-between; +} + +.docs-logo { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.docs-logo h2 { + margin: 0; + color: var(--tutor-text-primary); +} + +.docs-version { + background: var(--tutor-actions-brand-primary); + color: white; + padding: 0.125rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.docs-mobile-close { + display: none; +} + +@media (max-width: 768px) { + .docs-mobile-close { + display: block; + } +} + +/* Search */ +.docs-search { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--tutor-border-idle); +} + +.docs-search__input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--tutor-border-idle); + border-radius: 8px; + background: var(--tutor-surface-base); + color: var(--tutor-text-primary); + font-size: 0.875rem; + font-family: var(--docs-font-sans); +} + +.docs-search__input:focus { + outline: none; + border-color: var(--tutor-border-brand); + box-shadow: 0 0 0 3px var(--tutor-actions-brand-tertiary); +} + +/* Navigation */ +.docs-nav { + flex: 1; + padding: 1rem 0; + overflow-y: auto; +} + +.docs-nav__section { + margin-bottom: 2rem; +} + +.docs-nav__title { + font-size: 0.75rem; + font-weight: 600; + color: var(--tutor-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.75rem 0; + padding: 0 1.5rem; +} + +.docs-nav__list { + list-style: none; + margin: 0; + padding: 0; +} + +.docs-nav__item { + margin: 0; +} + +.docs-nav__link { + display: block; + padding: 0.5rem 1.5rem; + color: var(--tutor-text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: all 0.2s ease; + border-left: 3px solid transparent; +} + +[dir="rtl"] .docs-nav__link { + border-left: none; + border-right: 3px solid transparent; +} + +.docs-nav__link:hover { + background: var(--tutor-surface-l2); + color: var(--tutor-text-primary); +} + +.docs-nav__link--active { + background: var(--tutor-actions-brand-tertiary); + color: var(--tutor-text-brand); + border-left-color: var(--tutor-actions-brand-primary); + font-weight: 500; +} + +[dir="rtl"] .docs-nav__link--active { + border-left-color: transparent; + border-right-color: var(--tutor-actions-brand-primary); +} + +/* Main Content */ +.docs-main { + flex: 1; + margin-left: var(--docs-sidebar-width); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +[dir="rtl"] .docs-main { + margin-left: 0; + margin-right: var(--docs-sidebar-width); +} + +@media (max-width: 768px) { + .docs-main { + margin-left: 0; + margin-right: 0; + } +} + +/* Header */ +.docs-header { + height: var(--docs-header-height); + background: var(--tutor-surface-base); + border-bottom: 1px solid var(--tutor-border-idle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2rem; + position: sticky; + top: 0; + z-index: 100; +} + +@media (max-width: 768px) { + .docs-header { + padding: 0 1rem; + } +} + +.docs-header__left { + display: flex; + align-items: center; + gap: 1rem; +} + +.docs-header__right { + display: flex; + align-items: center; +} + +.docs-mobile-menu { + display: none; +} + +@media (max-width: 768px) { + .docs-mobile-menu { + display: block; + } +} + +.docs-breadcrumb { + font-size: 1rem; + font-weight: 500; + color: var(--tutor-text-primary); +} + +/* Controls */ +.docs-controls { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +@media (max-width: 640px) { + .docs-controls { + gap: 0.5rem; + } +} + +.docs-control { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.docs-control__label { + font-size: 0.875rem; + color: var(--tutor-text-secondary); + white-space: nowrap; +} + +.docs-control__select { + padding: 0.25rem 0.5rem; + border: 1px solid var(--tutor-border-idle); + border-radius: 4px; + background: var(--tutor-surface-base); + color: var(--tutor-text-primary); + font-size: 0.875rem; +} + +.docs-control__btn { + white-space: nowrap; +} + +/* Content */ +.docs-content { + flex: 1; + padding: 2rem; + max-width: 100%; + overflow-x: hidden; +} + +@media (max-width: 768px) { + .docs-content { + padding: 1rem; + } +} + +.docs-section { + max-width: 1200px; + margin: 0 auto; +} + +.docs-section__title { + font-size: 2.5rem; + font-weight: 700; + color: var(--tutor-text-primary); + margin: 0 0 2rem 0; + line-height: 1.2; +} + +/* Hero Section */ +.docs-hero { + text-align: center; + padding: 4rem 0; + margin-bottom: 4rem; +} + +.docs-hero__title { + font-size: 3.5rem; + font-weight: 800; + color: var(--tutor-text-primary); + margin: 0 0 1rem 0; + line-height: 1.1; + background: linear-gradient(135deg, var(--tutor-actions-brand-primary), var(--tutor-actions-brand-secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +@media (max-width: 768px) { + .docs-hero__title { + font-size: 2.5rem; + } +} + +.docs-hero__subtitle { + font-size: 1.25rem; + color: var(--tutor-text-secondary); + margin: 0 0 2rem 0; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +.docs-hero__actions { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +/* Features */ +.docs-features { + margin-bottom: 4rem; +} + +.docs-feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; +} + +.docs-feature-card { + background: var(--tutor-surface-l1); + border: 1px solid var(--tutor-border-idle); + border-radius: 12px; + padding: 2rem; + text-align: center; + transition: all 0.2s ease; +} + +.docs-feature-card:hover { + border-color: var(--tutor-border-brand); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.docs-feature-card__icon { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.docs-feature-card__title { + font-size: 1.25rem; + font-weight: 600; + color: var(--tutor-text-primary); + margin: 0 0 0.75rem 0; +} + +.docs-feature-card__description { + color: var(--tutor-text-secondary); + margin: 0; + line-height: 1.6; +} + +/* Examples */ +.docs-example { + margin-bottom: 3rem; + background: var(--tutor-surface-l1); + border: 1px solid var(--tutor-border-idle); + border-radius: 12px; + overflow: hidden; +} + +.docs-example__title { + font-size: 1.5rem; + font-weight: 600; + color: var(--tutor-text-primary); + margin: 0 0 0.75rem 0; + padding: 1.5rem 1.5rem 0; +} + +.docs-example__description { + color: var(--tutor-text-secondary); + margin: 0 0 1.5rem 0; + padding: 0 1.5rem; + line-height: 1.6; +} + +.docs-example__demo { + padding: 1.5rem; + background: var(--tutor-surface-base); + border-top: 1px solid var(--tutor-border-idle); + border-bottom: 1px solid var(--tutor-border-idle); +} + +/* Code Blocks */ +.docs-code-block { + background: var(--tutor-surface-l2); + border-radius: 8px; + overflow: hidden; + margin: 1rem 0; +} + +.docs-code-block__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--tutor-surface-l1); + border-bottom: 1px solid var(--tutor-border-idle); +} + +.docs-code-block__title { + font-size: 0.875rem; + font-weight: 500; + color: var(--tutor-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.docs-code-block__copy { + background: none; + border: 1px solid var(--tutor-border-idle); + color: var(--tutor-text-secondary); + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.docs-code-block__copy:hover { + background: var(--tutor-surface-l2); + border-color: var(--tutor-border-brand); + color: var(--tutor-text-primary); +} + +.docs-code-block__content { + margin: 0; + padding: 1rem; + font-family: var(--docs-font-mono); + font-size: 0.875rem; + line-height: 1.5; + color: var(--tutor-text-primary); + background: transparent; + overflow-x: auto; +} + +.docs-code-block__content code { + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + padding: 0; +} + +/* Token Architecture */ +.docs-token-architecture { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin: 2rem 0; + padding: 0 2rem; +} + +@media (max-width: 768px) { + .docs-token-architecture { + grid-template-columns: 1fr; + } +} + +.docs-token-type { + background: var(--tutor-surface-base); + border: 1px solid var(--tutor-border-idle); + border-radius: 8px; + padding: 1.5rem; +} + +.docs-token-type__title { + font-size: 1.25rem; + font-weight: 600; + color: var(--tutor-text-primary); + margin: 0 0 0.75rem 0; +} + +.docs-token-type__description { + color: var(--tutor-text-secondary); + margin: 0 0 1rem 0; +} + +.docs-token-type__list { + list-style: none; + margin: 0; + padding: 0; +} + +.docs-token-type__list li { + padding: 0.25rem 0; + color: var(--tutor-text-secondary); + position: relative; + padding-left: 1rem; +} + +.docs-token-type__list li::before { + content: '•'; + color: var(--tutor-actions-brand-primary); + position: absolute; + left: 0; +} + +/* Color System */ +.docs-color-system { + margin: 2rem 0; + padding: 0 2rem; +} + +.docs-color-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.docs-color-tab { + padding: 0.5rem 1rem; + border: 1px solid var(--tutor-border-idle); + background: var(--tutor-surface-base); + color: var(--tutor-text-secondary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; +} + +.docs-color-tab:hover { + border-color: var(--tutor-border-brand); + color: var(--tutor-text-primary); +} + +.docs-color-tab.active { + background: var(--tutor-actions-brand-primary); + border-color: var(--tutor-actions-brand-primary); + color: white; +} + +.docs-color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.docs-color-swatch { + background: var(--tutor-surface-base); + border: 1px solid var(--tutor-border-idle); + border-radius: 8px; + padding: 1rem; + text-align: center; + transition: all 0.2s ease; + cursor: pointer; + position: relative; +} + +.docs-color-swatch:hover { + border-color: var(--tutor-border-brand); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +.docs-color-swatch:hover::after { + content: 'Click to copy'; + position: absolute; + top: -2rem; + left: 50%; + transform: translateX(-50%); + background: var(--tutor-surface-l2); + color: var(--tutor-text-primary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.docs-color-swatch__color { + width: 100%; + height: 60px; + border-radius: 6px; + margin-bottom: 0.75rem; + border: 1px solid var(--tutor-border-idle); +} + +.docs-color-swatch__info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.docs-color-swatch__name { + font-weight: 500; + color: var(--tutor-text-primary); + font-size: 0.875rem; +} + +.docs-color-swatch__value { + font-family: var(--docs-font-mono); + font-size: 0.75rem; + color: var(--tutor-text-secondary); +} + +.docs-color-swatch__token { + font-family: var(--docs-font-mono); + font-size: 0.75rem; + color: var(--tutor-text-brand); + background: var(--tutor-actions-brand-tertiary); + padding: 0.125rem 0.375rem; + border-radius: 4px; +} + +/* Component sections are now individual pages */ + +/* Responsive Utilities */ +@media (max-width: 640px) { + .docs-hero { + padding: 2rem 0; + } + + .docs-hero__title { + font-size: 2rem; + } + + .docs-hero__subtitle { + font-size: 1rem; + } + + .docs-feature-grid { + grid-template-columns: 1fr; + } + + .docs-controls { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .docs-control { + justify-content: space-between; + } +} + +/* Dark theme adjustments */ +[data-theme="dark"] .docs-hero__title { + background: linear-gradient(135deg, #4979e8, #85e13a); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* RTL adjustments */ +[dir="rtl"] .docs-token-type__list li { + padding-left: 0; + padding-right: 1rem; +} + +[dir="rtl"] .docs-token-type__list li::before { + left: auto; + right: 0; +} + +/* Animation utilities */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.docs-section { + animation: fadeIn 0.3s ease; +} + +/* Focus styles for accessibility */ +.docs-nav__link:focus, +.docs-color-tab:focus, +.docs-component-nav__item:focus, +.docs-code-block__copy:focus, +.docs-search__input:focus { + outline: 2px solid var(--tutor-actions-brand-primary); + outline-offset: 2px; +} + +/* Print styles */ +@media print { + .docs-sidebar, + .docs-header { + display: none; + } + + .docs-main { + margin-left: 0; + margin-right: 0; + } + + .docs-content { + padding: 0; + } +} \ No newline at end of file diff --git a/assets/core/docs/assets/docs.js b/assets/core/docs/assets/docs.js new file mode 100644 index 0000000000..b7435af43a --- /dev/null +++ b/assets/core/docs/assets/docs.js @@ -0,0 +1,392 @@ +// Documentation App +function docsApp() { + return { + // State + activeSection: 'overview', + searchQuery: '', + mobileMenuOpen: false, + theme: 'light', + isRTL: false, + fontScale: 100, + isMobile: window.innerWidth <= 768, + + // Navigation structure + navigation: [ + { + id: 'getting-started', + title: 'Getting Started', + items: [ + { id: 'overview', title: 'Overview' }, + { id: 'installation', title: 'Installation' }, + { id: 'basic-usage', title: 'Basic Usage' } + ] + }, + { + id: 'design-system', + title: 'Design System', + items: [ + { id: 'design-tokens', title: 'Design Tokens' }, + { id: 'typography', title: 'Typography' }, + { id: 'colors', title: 'Colors' }, + { id: 'spacing', title: 'Spacing' } + ] + }, + { + id: 'components', + title: 'Components', + items: [ + { id: 'components', title: 'Overview' }, + { id: 'buttons', title: 'Buttons' }, + { id: 'cards', title: 'Cards' }, + { id: 'forms', title: 'Forms' }, + { id: 'alpine-components', title: 'Alpine.js Components' } + ] + }, + { + id: 'development', + title: 'Development', + items: [ + { id: 'sass-development', title: 'SASS Development' }, + { id: 'custom-components', title: 'Custom Components' }, + { id: 'utility-classes', title: 'Utility Classes' } + ] + }, + { + id: 'features', + title: 'Features', + items: [ + { id: 'font-scaling', title: 'Font Scaling' }, + { id: 'rtl-support', title: 'RTL Support' }, + { id: 'theming', title: 'Theming' } + ] + } + ], + + filteredNavigation: [], + + // Initialize + init() { + this.filteredNavigation = this.navigation; + this.detectTheme(); + this.detectRTL(); + this.detectFontScale(); + this.handleResize(); + + // Listen for resize events + window.addEventListener('resize', () => { + this.handleResize(); + }); + + // Listen for hash changes + window.addEventListener('hashchange', () => { + this.handleHashChange(); + }); + + // Handle initial hash + this.handleHashChange(); + }, + + // Navigation methods + setActiveSection(sectionId) { + this.activeSection = sectionId; + this.mobileMenuOpen = false; + + // Update URL hash + window.location.hash = sectionId; + + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + + getCurrentSection() { + for (const section of this.navigation) { + for (const item of section.items) { + if (item.id === this.activeSection) { + return item; + } + } + } + return null; + }, + + filterNavigation() { + if (!this.searchQuery.trim()) { + this.filteredNavigation = this.navigation; + return; + } + + const query = this.searchQuery.toLowerCase(); + this.filteredNavigation = this.navigation.map(section => ({ + ...section, + items: section.items.filter(item => + item.title.toLowerCase().includes(query) || + item.id.toLowerCase().includes(query) + ) + })).filter(section => section.items.length > 0); + }, + + handleHashChange() { + const hash = window.location.hash.slice(1); + if (hash && this.isValidSection(hash)) { + this.activeSection = hash; + } + }, + + isValidSection(sectionId) { + for (const section of this.navigation) { + for (const item of section.items) { + if (item.id === sectionId) { + return true; + } + } + } + return false; + }, + + handleResize() { + this.isMobile = window.innerWidth <= 768; + if (!this.isMobile) { + this.mobileMenuOpen = false; + } + }, + + // Theme methods + toggleTheme() { + this.theme = this.theme === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', this.theme); + localStorage.setItem('docs-theme', this.theme); + }, + + detectTheme() { + const savedTheme = localStorage.getItem('docs-theme'); + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + this.theme = savedTheme || systemTheme; + document.documentElement.setAttribute('data-theme', this.theme); + }, + + // RTL methods + toggleRTL() { + this.isRTL = !this.isRTL; + document.documentElement.setAttribute('dir', this.isRTL ? 'rtl' : 'ltr'); + localStorage.setItem('docs-rtl', this.isRTL); + }, + + detectRTL() { + const savedRTL = localStorage.getItem('docs-rtl'); + this.isRTL = savedRTL === 'true'; + document.documentElement.setAttribute('dir', this.isRTL ? 'rtl' : 'ltr'); + }, + + // Font scaling methods + setFontScale(scale) { + this.fontScale = parseInt(scale); + + // Remove existing font scale classes + document.documentElement.classList.remove( + 'tutor-font-scale-80', + 'tutor-font-scale-90', + 'tutor-font-scale-100', + 'tutor-font-scale-110', + 'tutor-font-scale-120' + ); + + // Add new font scale class + if (scale !== 100) { + document.documentElement.classList.add(`tutor-font-scale-${scale}`); + } + + localStorage.setItem('docs-font-scale', scale); + }, + + detectFontScale() { + const savedScale = localStorage.getItem('docs-font-scale'); + this.fontScale = savedScale ? parseInt(savedScale) : 100; + this.setFontScale(this.fontScale); + }, + + // Utility methods + copyToClipboard(event) { + const codeBlock = event.target.closest('.docs-code-block'); + const code = codeBlock.querySelector('.docs-code-block__content code'); + + if (code) { + const text = code.textContent; + + // Modern clipboard API + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(() => { + this.showCopyFeedback(event.target); + }); + } else { + // Fallback for older browsers + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + this.showCopyFeedback(event.target); + } + } + }, + + showCopyFeedback(button) { + const originalText = button.textContent; + button.textContent = 'Copied!'; + button.style.background = 'var(--tutor-actions-success-primary)'; + button.style.color = 'white'; + button.style.borderColor = 'var(--tutor-actions-success-primary)'; + + setTimeout(() => { + button.textContent = originalText; + button.style.background = ''; + button.style.color = ''; + button.style.borderColor = ''; + }, 2000); + } + }; +} + +// Color system data +const colorScales = { + brand: [ + { name: 'Brand 100', value: '#f6f8fe', token: '$tutor-brand-100' }, + { name: 'Brand 200', value: '#e4ebfc', token: '$tutor-brand-200' }, + { name: 'Brand 300', value: '#dbe4fa', token: '$tutor-brand-300' }, + { name: 'Brand 400', value: '#a4bcf4', token: '$tutor-brand-400' }, + { name: 'Brand 500', value: '#4979e8', token: '$tutor-brand-500' }, + { name: 'Brand 600', value: '#3e64de', token: '$tutor-brand-600' }, + { name: 'Brand 700', value: '#2b49ca', token: '$tutor-brand-700' }, + { name: 'Brand 800', value: '#293da4', token: '$tutor-brand-800' }, + { name: 'Brand 900', value: '#263782', token: '$tutor-brand-900' }, + { name: 'Brand 950', value: '#1c234f', token: '$tutor-brand-950' } + ], + gray: [ + { name: 'Gray 25', value: '#fcfcfd', token: '$tutor-gray-25' }, + { name: 'Gray 50', value: '#f9fafb', token: '$tutor-gray-50' }, + { name: 'Gray 100', value: '#f2f4f7', token: '$tutor-gray-100' }, + { name: 'Gray 200', value: '#eaecf0', token: '$tutor-gray-200' }, + { name: 'Gray 300', value: '#d0d5dd', token: '$tutor-gray-300' }, + { name: 'Gray 400', value: '#98a2b3', token: '$tutor-gray-400' }, + { name: 'Gray 500', value: '#667085', token: '$tutor-gray-500' }, + { name: 'Gray 600', value: '#475467', token: '$tutor-gray-600' }, + { name: 'Gray 700', value: '#344054', token: '$tutor-gray-700' }, + { name: 'Gray 800', value: '#1d2939', token: '$tutor-gray-800' }, + { name: 'Gray 900', value: '#101828', token: '$tutor-gray-900' }, + { name: 'Gray 950', value: '#0c111d', token: '$tutor-gray-950' } + ], + success: [ + { name: 'Success 25', value: '#fafef5', token: '$tutor-success-25' }, + { name: 'Success 50', value: '#f3fee7', token: '$tutor-success-50' }, + { name: 'Success 100', value: '#e3fbcc', token: '$tutor-success-100' }, + { name: 'Success 200', value: '#d0f8ab', token: '$tutor-success-200' }, + { name: 'Success 300', value: '#a6ef67', token: '$tutor-success-300' }, + { name: 'Success 400', value: '#85e13a', token: '$tutor-success-400' }, + { name: 'Success 500', value: '#66c61c', token: '$tutor-success-500' }, + { name: 'Success 600', value: '#4ca30d', token: '$tutor-success-600' }, + { name: 'Success 700', value: '#3b7c0f', token: '$tutor-success-700' }, + { name: 'Success 800', value: '#326212', token: '$tutor-success-800' }, + { name: 'Success 900', value: '#2b5314', token: '$tutor-success-900' }, + { name: 'Success 950', value: '#15290a', token: '$tutor-success-950' } + ], + warning: [ + { name: 'Warning 25', value: '#fffcf5', token: '$tutor-warning-25' }, + { name: 'Warning 50', value: '#fffaeb', token: '$tutor-warning-50' }, + { name: 'Warning 100', value: '#fef0c7', token: '$tutor-warning-100' }, + { name: 'Warning 200', value: '#fedf89', token: '$tutor-warning-200' }, + { name: 'Warning 300', value: '#fec84b', token: '$tutor-warning-300' }, + { name: 'Warning 400', value: '#fdb022', token: '$tutor-warning-400' }, + { name: 'Warning 500', value: '#f79009', token: '$tutor-warning-500' }, + { name: 'Warning 600', value: '#dc6803', token: '$tutor-warning-600' }, + { name: 'Warning 700', value: '#b54708', token: '$tutor-warning-700' }, + { name: 'Warning 800', value: '#93370d', token: '$tutor-warning-800' }, + { name: 'Warning 900', value: '#7a2e0e', token: '$tutor-warning-900' }, + { name: 'Warning 950', value: '#4e1d09', token: '$tutor-warning-950' } + ], + error: [ + { name: 'Error 25', value: '#fffbfa', token: '$tutor-error-25' }, + { name: 'Error 50', value: '#fef3f2', token: '$tutor-error-50' }, + { name: 'Error 100', value: '#fee4e2', token: '$tutor-error-100' }, + { name: 'Error 200', value: '#fecdca', token: '$tutor-error-200' }, + { name: 'Error 300', value: '#fda29b', token: '$tutor-error-300' }, + { name: 'Error 400', value: '#f97066', token: '$tutor-error-400' }, + { name: 'Error 500', value: '#f04438', token: '$tutor-error-500' }, + { name: 'Error 600', value: '#d92d20', token: '$tutor-error-600' }, + { name: 'Error 700', value: '#b42318', token: '$tutor-error-700' }, + { name: 'Error 800', value: '#912018', token: '$tutor-error-800' }, + { name: 'Error 900', value: '#7a271a', token: '$tutor-error-900' }, + { name: 'Error 950', value: '#55160c', token: '$tutor-error-950' } + ] +}; + +// Initialize when Alpine is ready +document.addEventListener('alpine:init', () => { + // Register color scales data + Alpine.store('colorScales', colorScales); +}); + +// Utility functions +window.docsUtils = { + // Format code for display + formatCode(code) { + return code.trim().replace(/^\s+/gm, ''); + }, + + // Generate component examples + generateButtonExample() { + return ` + + +`; + }, + + generateCardExample() { + return `
+
+

Card Title

+

Card subtitle

+
+
+

Card content goes here...

+
+ +
`; + }, + + generateFormExample() { + return `
+ + +

We'll never share your email.

+
+ +
+ + +
`; + } +}; + +// Performance optimization: Debounce search +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Add debounced search to the app +document.addEventListener('alpine:init', () => { + Alpine.data('docsApp', () => { + const app = docsApp(); + app.filterNavigation = debounce(app.filterNavigation.bind(app), 300); + return app; + }); +}); \ No newline at end of file diff --git a/assets/core/docs/components.html b/assets/core/docs/components.html new file mode 100644 index 0000000000..af4e7ec30f --- /dev/null +++ b/assets/core/docs/components.html @@ -0,0 +1,576 @@ + + + + + + + Components - Tutor Design System + + + + + + +
+ +
+

Component Showcase

+

Interactive examples of all Tutor Design System components

+ +
+ + + + ← Back to Docs + +
+
+ + +
+

Buttons

+ +
+
+
+

Button Variants

+
+
+
+
+ + + + +
+
+
+
+ +
+
+

Button Sizes

+
+
+
+
+ + + +
+
+
+
+ +
+
+

Button States

+
+
+
+
+ + + +
+
+
+
+
+
+ + +
+

Cards

+ +
+
+
+

Basic Card

+
+
+
+
+
+

Card Title

+

This is a subtitle

+
+
+

This is the card content. It can contain any HTML elements.

+
+ +
+
+
+
+ +
+
+

Elevated Card

+
+
+
+
+
+

Elevated Card

+

This card has more elevation for emphasis.

+ +
+
+
+
+
+ +
+
+

Interactive Card

+
+
+
+
+
+

Interactive Card

+

This card responds to hover and click interactions.

+
+
+
+
+
+
+
+ + +
+

Form Elements

+ +
+
+
+

Input Fields

+
+
+
+
+ + +

This field is required

+
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+

Form States

+
+
+
+
+ + +
+ +
+ + +

This field has an error

+
+ +
+ + +

This field is valid

+
+ +
+ + +
+
+
+
+
+
+ + +
+

Alpine.js Components

+ + +
+

Dropdown Component

+

Interactive dropdown with RTL support and keyboard navigation. +

+ +
+
+ +
+ Profile + Settings +
+ Logout +
+
+
+
+ + +
+

Modal Component

+

Modal with focus management and accessibility features.

+ +
+
+ + +
+
+
+
+
+

Modal Title

+ +
+
+

This is a modal dialog. You + can close it by clicking the X button, pressing Escape, or clicking outside.

+
+ +
+
+
+
+
+ + +
+

Tabs Component

+

Tab component with keyboard navigation and ARIA support.

+ +
+
+
+ + + +
+
+
+

Overview

+

This is the overview content. It provides a general introduction to the topic.

+
+
+

Details

+

Here are the detailed specifications and technical information.

+
+
+

Reviews

+

Customer reviews and feedback will be displayed here.

+
+
+
+
+
+ + +
+

Toast Notifications

+

Toast notification system with stacking and auto-dismiss.

+ +
+
+
+ + + + +
+ +
+ +
+
+
+
+
+ + +
+

Typography

+ +
+

Typography Scale

+

Complete typography system with consistent spacing and line + heights.

+ +
+

Heading 1 (40px)

+

Heading 2 (32px)

+

Heading 3 (24px)

+

Heading 4 (20px)

+
Heading 5 (18px)
+

Paragraph 1 (16px) - This is the default paragraph size for body text with + optimal line height for readability.

+

Paragraph 2 (14px) - This is smaller text for secondary content and supporting + information.

+

Paragraph 3 (12px) - This is the smallest text size for captions, labels, and + fine print.

+
+
+
+ + +
+

Utility Classes

+ +
+
+
+

Spacing Utilities

+
+
+
+
+
Padding 4 (8px) +
+
Padding 6 (16px) +
+
Padding 8 (24px) +
+
+
+
+
+ +
+
+

Color Utilities

+
+
+
+

Primary Text

+

Secondary Text

+

Brand Text

+

Success Text

+

Warning Text

+

Error Text

+
+
+
+ +
+
+

Layout Utilities

+
+
+
+
+ Left + Right +
+
+ Centered +
+
+
+ Grid 1
+
+ Grid 2
+
+
+
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/assets/core/docs/design-tokens.html b/assets/core/docs/design-tokens.html new file mode 100644 index 0000000000..77eff4c091 --- /dev/null +++ b/assets/core/docs/design-tokens.html @@ -0,0 +1,708 @@ + + + + + + Design Tokens - Tutor Design System + + + + + +
+ +
+

Design Tokens

+

Complete reference for all design tokens in the Tutor Design System

+ +
+ + + + ← Back to Docs + +
+
+ + +
+
+

Token Architecture

+

+ The design system uses a hybrid approach combining SASS variables and CSS custom properties for optimal performance and flexibility. +

+
+
+
+
+

SASS Variables

+

+ For static values that don't change between themes +

+
    +
  • Typography (font sizes, line heights)
  • +
  • Spacing (margins, padding)
  • +
  • Border radius
  • +
  • Breakpoints
  • +
+
+
+

CSS Custom Properties

+

+ For dynamic values that change between themes +

+
    +
  • Colors (text, background, borders)
  • +
  • Theme-dependent surfaces
  • +
  • Semantic color mappings
  • +
  • User customizable values
  • +
+
+
+
+
+ + +
+
+

Color System

+

+ Complete color scales based on Figma specifications with 10-step scales for brand, gray, and semantic colors. +

+
+
+ +
+ +
+ + + +
+
+ + +
+
+

Semantic Color Mappings

+

+ CSS custom properties that map to different colors based on the current theme. +

+
+
+
+ +
+

Text Colors

+
+
+
+
Primary Text
+
--tutor-text-primary
+
+
+
+
+
+
Secondary Text
+
--tutor-text-secondary
+
+
+
+
+
+
Disabled Text
+
--tutor-text-disabled
+
+
+
+
+
+
Brand Text
+
--tutor-text-brand
+
+
+
+ + +
+

Surface Colors

+
+
+
+
Base Surface
+
--tutor-surface-base
+
+
+
+
+
+
Level 1 Surface
+
--tutor-surface-l1
+
+
+
+
+
+
Level 2 Surface
+
--tutor-surface-l2
+
+
+
+ + +
+

Action Colors

+
+
+
+
Brand Primary
+
--tutor-actions-brand-primary
+
+
+
+
+
+
Success Primary
+
--tutor-actions-success-primary
+
+
+
+
+
+
Warning Primary
+
--tutor-actions-warning-primary
+
+
+
+
+
+
Critical Primary
+
--tutor-actions-critical-primary
+
+
+
+ + +
+

Border Colors

+
+
+
+
Idle Border
+
--tutor-border-idle
+
+
+
+
+
+
Brand Border
+
--tutor-border-brand
+
+
+
+
+
+
Critical Border
+
--tutor-border-critical
+
+
+
+
+
+
+ + +
+
+

Typography

+

+ Typography scale with consistent font sizes, line heights, and weights for optimal readability. +

+
+
+
+ +
+
+
+ + +
+
+

Spacing Scale

+

+ 22-step spacing scale (0-21) from 0px to 200px, matching Figma specifications exactly. +

+
+
+
+ +
+
+
+ + +
+
+

Border Radius

+

+ Border radius scale from none (0px) to full (1000px) with semantic naming. +

+
+
+
+ +
+
+
+ + +
+
+

Breakpoints

+

+ Responsive breakpoints for mobile-first design approach. +

+
+
+
+ +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/assets/core/docs/index.html b/assets/core/docs/index.html new file mode 100644 index 0000000000..dbc61296a9 --- /dev/null +++ b/assets/core/docs/index.html @@ -0,0 +1,1968 @@ + + + + + + + Tutor Design System - Developer Documentation + + + + + + + + +
+ +
+ + + + + +
+ +
+
+ +
+ +
+
+ +
+
+ +
+ + +
+ + + + + + +
+
+
+ + +
+ +
+
+

Tutor Design System

+

+ A comprehensive design system providing consistent UI components, themes, and utilities for + modern web applications. +

+
+ + +
+
+ +
+
+
+
🎨
+

Multi-theme Support

+

+ Light and dark themes with CSS custom properties based on Figma design tokens. +

+
+
+
🌍
+

RTL Language Support

+

+ Built-in right-to-left language support with automatic adaptation. +

+
+
+
🧩
+

Component Library

+

+ Pre-built components with Alpine.js integration and TypeScript support. +

+
+
+
📱
+

Responsive Design

+

+ Mobile-first responsive utilities with smart defaults. +

+
+
+
+

Performance Optimized

+

+ Single CSS and JS files for easy distribution and fast loading. +

+
+
+
🔧
+

TypeScript Support

+

+ Full TypeScript definitions with proper type safety (no any types). +

+
+
+
+
+ + +
+

Installation & Setup

+ +
+

Quick Start

+

+ Include the compiled CSS and JavaScript files in your HTML: +

+ +
+
+ HTML + +
+
<!DOCTYPE html>
+<html lang="en" dir="ltr" data-theme="light">
+<head>
+    <!-- Include the design system CSS -->
+    <link rel="stylesheet" href="assets/css/tutor-core.min.css" />
+</head>
+<body>
+    <!-- Your content here -->
+
+    <!-- Include Alpine.js and the design system JS -->
+    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    <script src="assets/js/tutor-core.js"></script>
+</body>
+</html>
+
+
+ +
+

Theme Configuration

+

+ Set the theme and direction attributes on the HTML element: +

+ +
+
+ HTML + +
+
<!-- Light theme, left-to-right -->
+<html lang="en" dir="ltr" data-theme="light">
+
+<!-- Dark theme, right-to-left -->
+<html lang="ar" dir="rtl" data-theme="dark">
+
+
+ +
+

JavaScript Theme Switching

+

+ Programmatically switch themes and directions: +

+ +
+
+ JavaScript + +
+
// Switch to dark theme
+document.documentElement.setAttribute('data-theme', 'dark');
+
+// Switch to light theme
+document.documentElement.setAttribute('data-theme', 'light');
+
+// Toggle RTL direction
+document.documentElement.setAttribute('dir', 'rtl');
+
+// Set font scale for accessibility
+TutorCore.utils.setFontScale(120); // 120% scaling
+
+
+
+ + +
+

Design Tokens

+ +
+

Token Architecture

+

+ The design system uses a hybrid approach combining SASS variables and CSS custom properties: +

+ +
+
+

SASS Variables

+

For static values that don't change between + themes

+
    +
  • Typography (font sizes, line heights)
  • +
  • Spacing (margins, padding)
  • +
  • Border radius
  • +
  • Breakpoints
  • +
+
+
+

CSS Custom Properties

+

For dynamic values that change between themes +

+
    +
  • Colors (text, background, borders)
  • +
  • Theme-dependent surfaces
  • +
  • Semantic color mappings
  • +
  • User customizable values
  • +
+
+
+
+ + +
+

Color System

+

+ Complete color scales based on Figma specifications with semantic mappings. +

+ +
+
+ + + + + +
+ + +
+
+
+
+
Brand 100
+
#f6f8fe
+
$tutor-brand-100
+
+
+
+
+
+
Brand 200
+
#e4ebfc
+
$tutor-brand-200
+
+
+
+
+
+
Brand 300
+
#dbe4fa
+
$tutor-brand-300
+
+
+
+
+
+
Brand 400
+
#a4bcf4
+
$tutor-brand-400
+
+
+
+
+
+
Brand 500
+
#4979e8
+
$tutor-brand-500
+
+
+
+
+
+
Brand 600
+
#3e64de
+
$tutor-brand-600
+
+
+
+
+
+
Brand 700
+
#2b49ca
+
$tutor-brand-700
+
+
+
+
+
+
Brand 800
+
#293da4
+
$tutor-brand-800
+
+
+
+
+
+
Brand 900
+
#263782
+
$tutor-brand-900
+
+
+
+
+
+
Brand 950
+
#1c234f
+
$tutor-brand-950
+
+
+
+ + +
+
+
+
+
Gray 25
+
#fcfcfd
+
$tutor-gray-25
+
+
+
+
+
+
Gray 50
+
#f9fafb
+
$tutor-gray-50
+
+
+
+
+
+
Gray 100
+
#f2f4f7
+
$tutor-gray-100
+
+
+
+
+
+
Gray 200
+
#eaecf0
+
$tutor-gray-200
+
+
+
+
+
+
Gray 300
+
#d0d5dd
+
$tutor-gray-300
+
+
+
+
+
+
Gray 400
+
#98a2b3
+
$tutor-gray-400
+
+
+
+
+
+
Gray 500
+
#667085
+
$tutor-gray-500
+
+
+
+
+
+
Gray 600
+
#475467
+
$tutor-gray-600
+
+
+
+
+
+
Gray 700
+
#344054
+
$tutor-gray-700
+
+
+
+
+
+
Gray 800
+
#1d2939
+
$tutor-gray-800
+
+
+
+
+
+
Gray 900
+
#101828
+
$tutor-gray-900
+
+
+
+
+
+
Gray 950
+
#0c111d
+
$tutor-gray-950
+
+
+
+ + +
+
+
+
+
Success 25
+
#fafef5
+
$tutor-success-25
+
+
+
+
+
+
Success 50
+
#f3fee7
+
$tutor-success-50
+
+
+
+
+
+
Success 100
+
#e3fbcc
+
$tutor-success-100
+
+
+
+
+
+
Success 200
+
#d0f8ab
+
$tutor-success-200
+
+
+
+
+
+
Success 300
+
#a6ef67
+
$tutor-success-300
+
+
+
+
+
+
Success 400
+
#85e13a
+
$tutor-success-400
+
+
+
+
+
+
Success 500
+
#66c61c
+
$tutor-success-500
+
+
+
+
+
+
Success 600
+
#4ca30d
+
$tutor-success-600
+
+
+
+
+
+
Success 700
+
#3b7c0f
+
$tutor-success-700
+
+
+
+
+
+
Success 800
+
#326212
+
$tutor-success-800
+
+
+
+
+
+
Success 900
+
#2b5314
+
$tutor-success-900
+
+
+
+
+
+
Success 950
+
#15290a
+
$tutor-success-950
+
+
+
+ + +
+
+
+
+
Warning 25
+
#fffcf5
+
$tutor-warning-25
+
+
+
+
+
+
Warning 50
+
#fffaeb
+
$tutor-warning-50
+
+
+
+
+
+
Warning 100
+
#fef0c7
+
$tutor-warning-100
+
+
+
+
+
+
Warning 200
+
#fedf89
+
$tutor-warning-200
+
+
+
+
+
+
Warning 300
+
#fec84b
+
$tutor-warning-300
+
+
+
+
+
+
Warning 400
+
#fdb022
+
$tutor-warning-400
+
+
+
+
+
+
Warning 500
+
#f79009
+
$tutor-warning-500
+
+
+
+
+
+
Warning 600
+
#dc6803
+
$tutor-warning-600
+
+
+
+
+
+
Warning 700
+
#b54708
+
$tutor-warning-700
+
+
+
+
+
+
Warning 800
+
#93370d
+
$tutor-warning-800
+
+
+
+
+
+
Warning 900
+
#7a2e0e
+
$tutor-warning-900
+
+
+
+
+
+
Warning 950
+
#4e1d09
+
$tutor-warning-950
+
+
+
+ + +
+
+
+
+
Error 25
+
#fffbfa
+
$tutor-error-25
+
+
+
+
+
+
Error 50
+
#fef3f2
+
$tutor-error-50
+
+
+
+
+
+
Error 100
+
#fee4e2
+
$tutor-error-100
+
+
+
+
+
+
Error 200
+
#fecdca
+
$tutor-error-200
+
+
+
+
+
+
Error 300
+
#fda29b
+
$tutor-error-300
+
+
+
+
+
+
Error 400
+
#f97066
+
$tutor-error-400
+
+
+
+
+
+
Error 500
+
#f04438
+
$tutor-error-500
+
+
+
+
+
+
Error 600
+
#d92d20
+
$tutor-error-600
+
+
+
+
+
+
Error 700
+
#b42318
+
$tutor-error-700
+
+
+
+
+
+
Error 800
+
#912018
+
$tutor-error-800
+
+
+
+
+
+
Error 900
+
#7a271a
+
$tutor-error-900
+
+
+
+
+
+
Error 950
+
#55160c
+
$tutor-error-950
+
+
+
+
+
+
+ + +
+

Components Overview

+ +
+

Available Components

+

+ The design system includes a comprehensive set of components for building modern web + applications. Click on any component below to view detailed documentation and examples. +

+ +
+
+
🔘
+

Buttons

+

+ Primary, secondary, outline, and ghost button variants with multiple sizes and + states. +

+
+ +
+
🃏
+

Cards

+

+ Flexible card components with headers, bodies, footers, and elevation options. +

+
+ +
+
📝
+

Forms

+

+ Input fields, textareas, selects, and validation states for comprehensive form + building. +

+
+ +
+
+

Alpine.js Components

+

+ Interactive components like dropdowns, modals, tabs, and toast notifications. +

+
+
+ + +
+
+ + +
+

Buttons

+ +
+

Button Variants

+

+ Four button variants for different use cases and visual hierarchy. +

+
+
+ + + + + + + + +
+
+ + + + + + +
+
+
+
+ HTML + +
+
<button class="tutor-btn tutor-btn-primary">Primary</button>
+<button class="tutor-btn tutor-btn-secondary">Secondary</button>
+<button class="tutor-btn tutor-btn-outline">Outline</button>
+<button class="tutor-btn tutor-btn-ghost">Ghost</button>
+
+
+ +
+

Button Sizes

+

+ Three button sizes to fit different contexts and layouts. +

+
+
+ + + + +
+
+ + + + +
+
+
+
+ HTML + +
+
<button class="tutor-btn tutor-btn-primary tutor-btn-small">Small</button>
+<button class="tutor-btn tutor-btn-primary tutor-btn-medium">Medium</button>
+<button class="tutor-btn tutor-btn-primary tutor-btn-large">Large</button>
+
+
+ +
+

Button States

+

+ Different button states for various interaction scenarios. +

+
+
+ + + +
+
+
+
+ HTML + +
+
<button class="tutor-btn tutor-btn-primary">Normal</button>
+<button class="tutor-btn tutor-btn-primary" disabled>Disabled</button>
+<button class="tutor-btn tutor-btn-primary tutor-btn-loading">Loading</button>
+
+
+
+ + +
+

Cards

+ +
+

Basic Card

+

+ Standard card component with header, body, and footer sections. +

+
+
+
+

Card Title

+

This is a subtitle

+
+
+

This is the card content. It can contain any HTML elements including text, + images, or other components.

+
+ +
+
+
+
+ HTML + +
+
<div class="tutor-card">
+  <div class="tutor-card__header">
+    <h4 class="tutor-card__title">Card Title</h4>
+    <p class="tutor-card__subtitle">This is a subtitle</p>
+  </div>
+  <div class="tutor-card__body">
+    <p>Card content goes here...</p>
+  </div>
+  <div class="tutor-card__footer">
+    <button class="tutor-btn tutor-btn--primary">Action</button>
+  </div>
+</div>
+
+
+ +
+

Card Variants

+

+ Different card styles for various use cases and visual emphasis. +

+
+
+
+
+
Default Card
+

Standard card with subtle border.

+
+
+
+
+
Elevated Card
+

Card with shadow for emphasis.

+
+
+
+
+
Interactive Card
+

Clickable card with hover effects.

+
+
+
+
+
+
+ HTML + +
+
<div class="tutor-card">Default Card</div>
+<div class="tutor-card tutor-card-elevated">Elevated Card</div>
+<div class="tutor-card tutor-card-interactive">Interactive Card</div>
+
+
+
+ + +
+

Forms

+ +
+

Form Elements

+

+ Complete set of form elements with consistent styling and validation states. +

+
+
+
+ + +

This field is required

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ HTML + +
+
<div class="tutor-form-group">
+  <label class="tutor-label" for="name">Full Name</label>
+  <input type="text" id="name" class="tutor-input" placeholder="Enter your name">
+  <p class="tutor-help-text">This field is required</p>
+</div>
+
+<div class="tutor-form-group">
+  <label class="tutor-label" for="message">Message</label>
+  <textarea id="message" class="tutor-textarea" placeholder="Enter your message"></textarea>
+</div>
+
+
+ +
+

Form States

+

+ Different form states for validation feedback and user guidance. +

+
+
+
+ + +
+ +
+ + +

This field has an error

+
+ +
+ + +

This field is valid

+
+ +
+ + +
+
+
+
+
+ HTML + +
+
<input type="text" class="tutor-input" placeholder="Normal">
+<input type="text" class="tutor-input tutor-input--error" placeholder="Error">
+<input type="text" class="tutor-input tutor-input--success" placeholder="Success">
+<input type="text" class="tutor-input" placeholder="Disabled" disabled>
+
+
+
+ + +
+

Alpine.js Components

+ +
+

Interactive Components

+

+ Advanced components powered by Alpine.js for rich user interactions with built-in RTL + support and accessibility features. +

+
+ + +
+

Dropdown

+

+ Interactive dropdown with RTL-aware positioning, keyboard navigation, and + click-outside-to-close functionality. +

+
+
+ +
+ Profile + Settings +
+ Logout +
+
+
+
+
+ HTML + +
+
<div x-data="TutorCore.dropdown({ placement: 'bottom-start', offset: 8 })" class="tutor-dropdown">
+  <button @click="toggle()" class="tutor-btn tutor-btn--primary">
+    Options <span x-text="open ? '▲' : '▼'"></span>
+  </button>
+  <div x-show="open" @click.outside="close()" class="tutor-dropdown__menu">
+    <a href="#" class="tutor-dropdown__item">Profile</a>
+    <a href="#" class="tutor-dropdown__item">Settings</a>
+    <hr class="tutor-my-2">
+    <a href="#" class="tutor-dropdown__item tutor-text-error">Logout</a>
+  </div>
+</div>
+
+
+ + +
+

Modal

+

+ Modal dialog with focus management, backdrop click to close, ESC key support, and + accessibility features. +

+
+
+ + +
+
+
+
+

Modal Title

+ +
+
+

This is a modal dialog. You can close it by clicking the + X button, pressing Escape, or clicking outside.

+
+ +
+
+
+
+
+
+ HTML + +
+
<div x-data="TutorCore.modal({ closable: true, backdrop: true, keyboard: true })">
+  <button @click="show()" class="tutor-btn tutor-btn--secondary">Open Modal</button>
+
+  <div x-show="open" class="tutor-modal" x-transition>
+    <div class="tutor-modal__backdrop" @click="hide()"></div>
+    <div class="tutor-modal__content" @keydown.escape="hide()">
+      <div class="tutor-modal__header">
+        <h3 class="tutor-h4">Modal Title</h3>
+        <button @click="hide()" class="tutor-modal__close">×</button>
+      </div>
+      <div class="tutor-modal__body">
+        <p class="tutor-p1">Modal content goes here...</p>
+      </div>
+      <div class="tutor-modal__footer">
+        <button @click="hide()" class="tutor-btn tutor-btn--secondary">Cancel</button>
+        <button @click="hide()" class="tutor-btn tutor-btn--primary">Confirm</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+
+ + +
+

Tabs

+

+ Tab component with keyboard navigation, ARIA support, and smooth content transitions. +

+
+
+
+ + + +
+
+
+

Overview

+

This is the overview content. It provides a general + introduction to the topic with key highlights and important information.

+
+
+

Details

+

Here are the detailed specifications and technical + information. This section contains comprehensive documentation and usage + guidelines.

+
+
+

Reviews

+

Customer reviews and feedback will be displayed here. This + includes ratings, comments, and user testimonials.

+
+
+
+
+
+
+ HTML + +
+
<div x-data="TutorCore.tabs({ defaultTab: 0 })" class="tutor-tabs">
+  <div class="tutor-tabs__nav">
+    <button @click="setTab(0)" :class="{'active': isActive(0)}" class="tutor-tab">Overview</button>
+    <button @click="setTab(1)" :class="{'active': isActive(1)}" class="tutor-tab">Details</button>
+    <button @click="setTab(2)" :class="{'active': isActive(2)}" class="tutor-tab">Reviews</button>
+  </div>
+  <div class="tutor-tabs__content">
+    <div x-show="isActive(0)" x-transition class="tutor-tab-panel">
+      <h4 class="tutor-h4">Overview</h4>
+      <p class="tutor-p1">Overview content...</p>
+    </div>
+    <div x-show="isActive(1)" x-transition class="tutor-tab-panel">
+      <h4 class="tutor-h4">Details</h4>
+      <p class="tutor-p1">Details content...</p>
+    </div>
+    <div x-show="isActive(2)" x-transition class="tutor-tab-panel">
+      <h4 class="tutor-h4">Reviews</h4>
+      <p class="tutor-p1">Reviews content...</p>
+    </div>
+  </div>
+</div>
+
+
+ + +
+

Toast Notifications

+

+ Toast notification system with multiple types, stacking support, auto-dismiss, and smooth + animations. +

+
+
+
+ + + + +
+ +
+ +
+
+
+
+
+ HTML + +
+
<div x-data="TutorCore.toast()">
+  <button @click="success('Success message!')" class="tutor-btn tutor-btn--primary">
+    Success Toast
+  </button>
+  <button @click="error('Error message!')" class="tutor-btn tutor-btn--secondary">
+    Error Toast
+  </button>
+  <button @click="warning('Warning message!')" class="tutor-btn tutor-btn--outline">
+    Warning Toast
+  </button>
+  <button @click="info('Info message!')" class="tutor-btn tutor-btn--ghost">
+    Info Toast
+  </button>
+
+  <div class="tutor-toast-container">
+    <template x-for="toast in toasts" :key="toast.id">
+      <div class="tutor-toast" :class="`tutor-toast--${toast.type}`" x-transition>
+        <span x-text="toast.message"></span>
+        <button @click="remove(toast.id)" class="tutor-toast__close">×</button>
+      </div>
+    </template>
+  </div>
+</div>
+
+
+ + +
+

Accordion

+

+ Accordion component with single or multiple panel support, smooth collapse animations, and + configurable default states. +

+
+
+
+ +
+

This panel contains information about getting started with + the design system. It includes setup instructions and basic usage examples. +

+
+
+
+ +
+

This panel covers advanced features like theming, RTL + support, and custom component development.

+
+
+
+ +
+

Learn about best practices for using the design system + effectively in your projects.

+
+
+
+
+
+
+ HTML + +
+
<div x-data="TutorCore.accordion({ multiple: false, defaultOpen: [0] })" class="tutor-accordion">
+  <div class="tutor-accordion__item">
+    <button @click="toggle(0)" class="tutor-accordion__trigger">
+      <span class="tutor-font-medium">Panel 1</span>
+      <span :class="{ 'tutor-rotate-180': isOpen(0) }" class="tutor-transition-transform">▼</span>
+    </button>
+    <div x-show="isOpen(0)" x-collapse class="tutor-accordion__content">
+      <p class="tutor-p1">Panel 1 content...</p>
+    </div>
+  </div>
+  <div class="tutor-accordion__item">
+    <button @click="toggle(1)" class="tutor-accordion__trigger">
+      <span class="tutor-font-medium">Panel 2</span>
+      <span :class="{ 'tutor-rotate-180': isOpen(1) }" class="tutor-transition-transform">▼</span>
+    </button>
+    <div x-show="isOpen(1)" x-collapse class="tutor-accordion__content">
+      <p class="tutor-p1">Panel 2 content...</p>
+    </div>
+  </div>
+</div>
+
+
+ + +
+

More Examples

+

+ For more comprehensive examples and advanced usage patterns, visit the dedicated components + page. +

+ +
+
+ + +
+

Basic Usage

+ +
+

Typography

+

+ The design system provides a complete typography scale with consistent spacing and line + heights. +

+
+

Heading 1 (40px)

+

Heading 2 (32px)

+

Heading 3 (24px)

+

Heading 4 (20px)

+
Heading 5 (18px)
+

Paragraph 1 (16px) - This is the default paragraph size for body text. +

+

Paragraph 2 (14px) - This is smaller text for secondary content.

+

Paragraph 3 (12px) - This is the smallest text size for captions and + labels.

+
+
+ +
+

Quick Component Examples

+

+ Get started quickly with these common component patterns. +

+
+
+ +
+

Buttons

+
+ + + +
+
+ + +
+

Card

+
+
+
Simple Card
+

This is a basic card example with minimal content.

+ +
+
+
+
+
+
+
+ HTML + +
+
<!-- Buttons -->
+<button class="tutor-btn tutor-btn--primary">Primary</button>
+<button class="tutor-btn tutor-btn--secondary">Secondary</button>
+
+<!-- Simple Card -->
+<div class="tutor-card">
+  <div class="tutor-card__body">
+    <h5>Card Title</h5>
+    <p>Card content goes here...</p>
+    <button class="tutor-btn tutor-btn--primary">Action</button>
+  </div>
+</div>
+
+
+
+ + +
+

Typography

+ +
+

Typography Scale

+

+ Complete typography system with consistent font sizes, line heights, and weights optimized + for readability. +

+
+
+
+

Heading 1

+
40px / 48px line-height / 700 weight +
+
+
+

Heading 2

+
32px / 40px line-height / 600 weight +
+
+
+

Heading 3

+
24px / 32px line-height / 600 weight +
+
+
+

Paragraph 1 - Primary body text

+
16px / 24px line-height / 400 weight +
+
+
+

Paragraph 2 - Secondary text

+
14px / 20px line-height / 400 weight +
+
+
+
+
+
+ + +
+

Colors

+ +
+

Text Colors

+

+ Semantic text colors that automatically adapt to light and dark themes. +

+
+
+

Primary text color - tutor-text-primary

+

Secondary text color - tutor-text-secondary

+

Disabled text color - tutor-text-disabled

+

Brand text color - tutor-text-brand

+

Success text color - tutor-text-success

+

Warning text color - tutor-text-warning

+

Error text color - tutor-text-error

+
+
+
+
+ HTML + +
+
<p class="tutor-text-primary">Primary text</p>
+<p class="tutor-text-secondary">Secondary text</p>
+<p class="tutor-text-brand">Brand text</p>
+<p class="tutor-text-success">Success text</p>
+<p class="tutor-text-warning">Warning text</p>
+<p class="tutor-text-error">Error text</p>
+
+
+ +
+

Background Colors

+

+ Surface and background colors for creating visual hierarchy and depth. +

+
+
+
+ Base Surface - tutor-bg-surface-base +
+
+ Level 1 Surface - tutor-bg-surface-l1 +
+
+ Level 2 Surface - tutor-bg-surface-l2 +
+
+
+
+
+ + +
+

Spacing

+ +
+

Spacing Scale

+

+ 22-step spacing scale (0-21) from 0px to 200px, matching Figma specifications exactly. +

+
+
+
+
Step 0
+
+
+ 0px +
+
+
Step 4
+
+
+ 8px +
+
+
Step 6
+
+
+ 16px +
+
+
Step 8
+
+
+ 24px +
+
+
Step 12
+
+
+ 40px +
+
+
+
+
+ HTML + +
+
<!-- Margin utilities -->
+<div class="tutor-m-4">Margin on all sides (8px)</div>
+<div class="tutor-mt-6">Margin top (16px)</div>
+<div class="tutor-mx-8">Horizontal margin (24px)</div>
+
+<!-- Padding utilities -->
+<div class="tutor-p-4">Padding on all sides (8px)</div>
+<div class="tutor-pt-6">Padding top (16px)</div>
+<div class="tutor-px-8">Horizontal padding (24px)</div>
+
+
+
+ + +
+

SASS Development

+
+

Coming Soon

+

+ SASS development guide with mixins and custom component creation will be available soon. +

+ +
+
+ +
+

Custom Components

+
+

Coming Soon

+

+ Guide for creating custom components using the design system will be available soon. +

+ +
+
+ +
+

Utility Classes

+
+

Coming Soon

+

+ Complete utility class reference will be available soon. +

+ +
+
+ +
+

Font Scaling

+
+

Accessibility Font Scaling

+

+ The design system supports font scaling from 80% to 120% for accessibility. Try the font + scale control in the header to see it in action. +

+
+
+
+

Sample Text

+

This text will scale when you change the font scale setting in + the header controls.

+
+
+
+
+
+ JavaScript + +
+
// Set font scale programmatically
+TutorCore.utils.setFontScale(120); // 120% scaling
+
+// Or add CSS class directly
+document.documentElement.classList.add('tutor-font-scale-120');
+
+
+
+ +
+

RTL Support

+
+

Right-to-Left Language Support

+

+ The design system automatically adapts to RTL layouts. Try the RTL toggle in the header to + see the layout change. +

+
+
+
+

RTL Example

+

This card will automatically flip its layout when RTL mode is enabled. Notice how + the text alignment and spacing adapt.

+ +
+
+
+
+
+ HTML + +
+
<!-- Enable RTL -->
+<html dir="rtl" data-theme="light">
+
+<!-- All components automatically adapt -->
+<div class="tutor-card">
+  <div class="tutor-card__body">
+    <p>This content will be right-aligned in RTL mode</p>
+  </div>
+</div>
+
+
+
+ +
+

Theming

+
+

Light and Dark Themes

+

+ The design system includes built-in light and dark themes. Try the theme toggle in the + header to switch between them. +

+
+
+
+

Theme-Aware + Content

+

This content automatically + adapts to the current theme using CSS custom properties.

+
+
+ + +
+
+
+
+
+ JavaScript + +
+
// Switch to dark theme
+document.documentElement.setAttribute('data-theme', 'dark');
+
+// Switch to light theme
+document.documentElement.setAttribute('data-theme', 'light');
+
+// Detect user's preferred theme
+const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+const theme = prefersDark ? 'dark' : 'light';
+document.documentElement.setAttribute('data-theme', theme);
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/assets/core/docs/quick-start.html b/assets/core/docs/quick-start.html new file mode 100644 index 0000000000..0c2a7e5c2a --- /dev/null +++ b/assets/core/docs/quick-start.html @@ -0,0 +1,593 @@ + + + + + + Quick Start Guide - Tutor Design System + + + + + +
+ +
+ +
+ + +
+

Quick Start Guide

+

Get up and running with the Tutor Design System in minutes

+ +
+ + +
+
1
+

Include the Design System Files

+

+ Add the compiled CSS and JavaScript files to your HTML document. The design system provides minified versions for production use. +

+ +
+ +
<!DOCTYPE html>
+<html lang="en" dir="ltr" data-theme="light">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>My Tutor App</title>
+    
+    <!-- Tutor Design System CSS -->
+    <link rel="stylesheet" href="assets/css/tutor-core.min.css">
+  </head>
+  <body>
+    <!-- Your content here -->
+    
+    <!-- Alpine.js (required for interactive components) -->
+    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    
+    <!-- Tutor Design System JS -->
+    <script src="assets/js/tutor-core.js"></script>
+  </body>
+</html>
+
+ +
+ 💡 Tip: + Use the minified versions (.min.css and .min.js) in production for better performance. +
+
+ + +
+
2
+

Add Your First Components

+

+ Start with basic components like buttons, cards, and form elements. All components follow consistent naming conventions and work seamlessly together. +

+ +
+ +
<!-- Buttons -->
+<button class="tutor-btn tutor-btn--primary">Primary Button</button>
+<button class="tutor-btn tutor-btn--secondary">Secondary Button</button>
+
+<!-- Card Component -->
+<div class="tutor-card">
+  <div class="tutor-card__header">
+    <h3 class="tutor-card__title">Welcome to Tutor</h3>
+    <p class="tutor-card__subtitle">Start learning today</p>
+  </div>
+  <div class="tutor-card__body">
+    <p>Discover thousands of courses and start your learning journey.</p>
+  </div>
+  <div class="tutor-card__footer">
+    <button class="tutor-btn tutor-btn--primary">Get Started</button>
+  </div>
+</div>
+
+ +
+

Live Preview:

+
+ + +
+ +
+
+

Welcome to Tutor

+

Start learning today

+
+
+

Discover thousands of courses and start your learning journey.

+
+ +
+
+
+ + +
+
3
+

Add Interactive Components

+

+ Use Alpine.js powered components for advanced interactions. The TutorCore class provides factory methods for creating component data objects. +

+ +
+ +
<!-- Dropdown Component -->
+<div x-data="TutorCore.dropdown()" class="tutor-dropdown">
+  <button @click="toggle()" class="tutor-btn tutor-btn--primary">
+    Options <span x-text="open ? '▲' : '▼'"></span>
+  </button>
+  <div x-show="open" @click.outside="close()" class="tutor-dropdown__menu">
+    <a href="#" class="tutor-dropdown__item">Profile</a>
+    <a href="#" class="tutor-dropdown__item">Settings</a>
+    <a href="#" class="tutor-dropdown__item">Logout</a>
+  </div>
+</div>
+
+<!-- Modal Component -->
+<div x-data="TutorCore.modal()">
+  <button @click="show()" class="tutor-btn tutor-btn--secondary">Open Modal</button>
+  
+  <div x-show="open" class="tutor-modal">
+    <div class="tutor-modal__backdrop" @click="hide()"></div>
+    <div class="tutor-modal__content">
+      <div class="tutor-modal__header">
+        <h3>Modal Title</h3>
+        <button @click="hide()" class="tutor-modal__close">×</button>
+      </div>
+      <div class="tutor-modal__body">
+        <p>Modal content goes here...</p>
+      </div>
+    </div>
+  </div>
+</div>
+
+ +
+

Live Preview:

+
+
+ + +
+ +
+ + +
+
+
+
+

Modal Title

+ +
+
+

This is a modal dialog example.

+
+
+
+
+
+
+
+ + +
+
4
+

Configure Themes and Features

+

+ Enable theme switching, RTL support, and font scaling for a complete user experience. All features work together seamlessly. +

+ +
+ +
<script>
+// Theme switching
+function toggleTheme() {
+  const html = document.documentElement;
+  const currentTheme = html.getAttribute('data-theme');
+  const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+  html.setAttribute('data-theme', newTheme);
+}
+
+// RTL support
+function toggleRTL() {
+  const html = document.documentElement;
+  const currentDir = html.getAttribute('dir');
+  const newDir = currentDir === 'ltr' ? 'rtl' : 'ltr';
+  html.setAttribute('dir', newDir);
+}
+
+// Font scaling for accessibility
+function setFontScale(scale) {
+  // Remove existing classes
+  document.documentElement.classList.remove(
+    'tutor-font-scale-80', 'tutor-font-scale-90', 
+    'tutor-font-scale-100', 'tutor-font-scale-110', 
+    'tutor-font-scale-120'
+  );
+  
+  // Add new scale class
+  if (scale !== 100) {
+    document.documentElement.classList.add(`tutor-font-scale-${scale}`);
+  }
+}
+</script>
+
+<!-- Theme controls -->
+<div class="theme-controls">
+  <button onclick="toggleTheme()" class="tutor-btn tutor-btn--outline">
+    🌓 Toggle Theme
+  </button>
+  <button onclick="toggleRTL()" class="tutor-btn tutor-btn--outline">
+    🔄 Toggle RTL
+  </button>
+  <select onchange="setFontScale(this.value)" class="tutor-select">
+    <option value="80">80% Font Size</option>
+    <option value="90">90% Font Size</option>
+    <option value="100" selected>100% Font Size</option>
+    <option value="110">110% Font Size</option>
+    <option value="120">120% Font Size</option>
+  </select>
+</div>
+
+ +
+

Live Preview:

+
+ + + +
+
+
+ + +
+

✅ You're Ready to Build!

+
    +
  • Design system files are included in your project
  • +
  • Basic components are working correctly
  • +
  • Interactive components respond to user actions
  • +
  • Theme switching and accessibility features are enabled
  • +
  • Your app supports both LTR and RTL layouts
  • +
+
+ + + + + +
+

Need Help?

+

+ Check out these resources for additional support and advanced usage patterns. +

+ +
+
+ + + + + + + \ No newline at end of file diff --git a/assets/core/docs/semantic-tokens.md b/assets/core/docs/semantic-tokens.md new file mode 100644 index 0000000000..2944197b6b --- /dev/null +++ b/assets/core/docs/semantic-tokens.md @@ -0,0 +1,167 @@ +# Semantic Token Files + +## Overview + +Semantic tokens are organized into separate files by category, each containing CSS variables that are theme-aware. This approach eliminates the need for separate theme override variables since CSS variables automatically handle theme switching. + +## File Structure + +``` +tokens/ +├── _actions.scss # Action states (success, warning, error) +├── _borders.scss # Border colors and radius +├── _buttons.scss # Button colors and states +├── _colors.scss # Primitive color palette +├── _icons.scss # Icon colors +├── _surfaces.scss # Background/surface colors +├── _tabs.scss # Tab navigation colors +├── _text-colors.scss # Text colors +├── _typography.scss # Typography tokens +├── _spacing.scss # Spacing scale +├── _zIndex.scss # Z-index scale +└── _index.scss # Exports all tokens +``` + +## Usage Pattern + +All semantic token files follow the same pattern: + +```scss +// CSS Variable References (theme-aware) +$tutor-surface-base: var(--tutor-surface-base); +$tutor-text-primary: var(--tutor-text-primary); +$tutor-button-primary: var(--tutor-button-primary); + +// Maps for utility generation +$tutor-surfaces: ( + base: $tutor-surface-base, + l1: $tutor-surface-l1, // ... +); +``` + +## Benefits + +### ✅ **Simplified Structure** + +- No need for separate theme override variables +- CSS variables handle theme switching automatically +- Cleaner, more maintainable code + +### ✅ **Automatic Theme Support** + +- CSS variables like `var(--tutor-surface-base)` automatically switch between light/dark themes +- Theme definitions are handled in `themes/_light.scss` and `themes/_dark.scss` + +### ✅ **Better Organization** + +- Each category has its own file +- Easy to find and update specific token types +- Clear separation of concerns + +## How It Works + +1. **Theme files** (`themes/_light.scss`, `themes/_dark.scss`) define the actual color values: + + ```scss + // Light theme + :root { + --tutor-surface-base: #fafafa; + --tutor-text-primary: #0c111d; + } + + // Dark theme + [data-theme='dark'] { + --tutor-surface-base: #161b26; + --tutor-text-primary: #ffffff; + } + ``` + +2. **Semantic token files** reference these CSS variables: + + ```scss + $tutor-surface-base: var(--tutor-surface-base); + $tutor-text-primary: var(--tutor-text-primary); + ``` + +3. **Components** use the semantic tokens: + ```scss + .my-component { + background-color: $tutor-surface-base; + color: $tutor-text-primary; + } + ``` + +## Token Categories + +### **Surfaces** (`_surfaces.scss`) + +Background colors for containers, cards, and layouts. + +- `surface-base`, `surface-l1`, `surface-l2` +- `surface-brand-primary`, `surface-brand-secondary` + +### **Text Colors** (`_text-colors.scss`) + +All text content colors. + +- `text-primary`, `text-secondary`, `text-subdued` +- `text-brand`, `text-success`, `text-warning`, `text-critical` + +### **Buttons** (`_buttons.scss`) + +Interactive button element colors. + +- `button-primary`, `button-secondary`, `button-destructive` +- Includes hover, focus, and disabled states + +### **Borders** (`_borders.scss`) + +Border colors and radius values. + +- `border-idle`, `border-hover`, `border-focus` +- `border-brand`, `border-success`, `border-error` + +### **Icons** (`_icons.scss`) + +Icon-specific colors. + +- `icon-idle`, `icon-hover`, `icon-secondary` +- `icon-brand`, `icon-success`, `icon-warning` + +### **Actions** (`_actions.scss`) + +State and feedback colors. + +- `actions-success-*`, `actions-warning-*` +- `actions-brand-*`, `actions-critical-*` + +### **Tabs** (`_tabs.scss`) + +Navigation and tab component colors. + +- `tab-sidebar-*`, `tab-l3-*` +- Includes hover and active states + +## Migration from Old Approach + +### Before (with theme overrides): + +```scss +// Light theme defaults +$tutor-surface-primary: $tutor-gray-1; + +// Dark theme overrides +$tutor-surface-primary-dark: $tutor-gray-950; + +// CSS variable reference +$tutor-surface-primary-var: var(--tutor-surface-primary); +``` + +### After (simplified): + +```scss +// Direct CSS variable reference (theme-aware) +$tutor-surface-primary: var(--tutor-surface-primary); +``` + +The CSS variables automatically handle the theme switching, making the code much cleaner and easier to maintain! diff --git a/assets/core/docs/start.html b/assets/core/docs/start.html new file mode 100644 index 0000000000..aeb40968e6 --- /dev/null +++ b/assets/core/docs/start.html @@ -0,0 +1,239 @@ + + + + + + + Tutor Design System Documentation + + + + + + +
+ +
+ + +
+

Tutor Design System

+

Complete documentation with live examples and interactive components

+ +
+ +
+ + + + + + + +
+

Key Features

+
+
+
🎨
+

Multi-theme Support

+

Light and dark themes + with CSS custom properties

+
+
+
🌍
+

RTL Language Support

+

Built-in + right-to-left language support

+
+
+
+

Alpine.js Integration

+

Interactive + components with TypeScript support

+
+
+
+

Accessibility First

+

Font scaling and WCAG + compliant components

+
+
+
+
+ + + + + \ No newline at end of file diff --git a/assets/core/docs/utility-classes.md b/assets/core/docs/utility-classes.md new file mode 100644 index 0000000000..f927cb0db7 --- /dev/null +++ b/assets/core/docs/utility-classes.md @@ -0,0 +1,1072 @@ +# Tutor CSS Utility Classes Documentation + +A comprehensive guide to all utility classes available in the Tutor design system. These utilities provide a consistent, scalable approach to styling with built-in RTL support and responsive design. + +## Table of Contents + +1. [Layout Utilities](#layout-utilities) +2. [Spacing Utilities](#spacing-utilities) +3. [Typography Utilities](#typography-utilities) +4. [Color Utilities](#color-utilities) +5. [Sizing Utilities](#sizing-utilities) +6. [Border Utilities](#border-utilities) +7. [RTL Utilities](#rtl-utilities) +8. [Z-Index Utilities](#z-index-utilities) +9. [Responsive Design](#responsive-design) +10. [Design Tokens](#design-tokens) + +--- + +## Layout Utilities + +### Display + +Control the display behavior of elements. + +```css +.tutor-block /* display: block */ +.tutor-inline-block /* display: inline-block */ +.tutor-inline /* display: inline */ +.tutor-flex /* display: flex */ +.tutor-inline-flex /* display: inline-flex */ +.tutor-grid /* display: grid */ +.tutor-inline-grid /* display: inline-grid */ +.tutor-hidden /* display: none */ +``` + +### Flexbox + +#### Direction (RTL-aware) + +```css +.tutor-flex-row /* flex-direction: row (RTL: row-reverse) */ +.tutor-flex-row-reverse /* flex-direction: row-reverse (RTL: row) */ +.tutor-flex-col /* flex-direction: column */ +.tutor-flex-col-reverse /* flex-direction: column-reverse */ +``` + +#### Wrap + +```css +.tutor-flex-wrap /* flex-wrap: wrap */ +.tutor-flex-wrap-reverse /* flex-wrap: wrap-reverse */ +.tutor-flex-nowrap /* flex-wrap: nowrap */ +``` + +#### Flex Properties + +```css +.tutor-flex-1 /* flex: 1 1 0% */ +.tutor-flex-auto /* flex: 1 1 auto */ +.tutor-flex-initial /* flex: 0 1 auto */ +.tutor-flex-none /* flex: none */ +.tutor-grow /* flex-grow: 1 */ +.tutor-grow-0 /* flex-grow: 0 */ +.tutor-shrink /* flex-shrink: 1 */ +.tutor-shrink-0 /* flex-shrink: 0 */ +``` + +#### Justify Content (RTL-aware) + +```css +.tutor-justify-start /* justify-content: flex-start (RTL: flex-end) */ +.tutor-justify-end /* justify-content: flex-end (RTL: flex-start) */ +.tutor-justify-center /* justify-content: center */ +.tutor-justify-between /* justify-content: space-between */ +.tutor-justify-around /* justify-content: space-around */ +.tutor-justify-evenly /* justify-content: space-evenly */ +``` + +#### Align Items + +```css +.tutor-items-start /* align-items: flex-start */ +.tutor-items-end /* align-items: flex-end */ +.tutor-items-center /* align-items: center */ +.tutor-items-baseline /* align-items: baseline */ +.tutor-items-stretch /* align-items: stretch */ +``` + +#### Align Content + +```css +.tutor-content-start /* align-content: flex-start */ +.tutor-content-end /* align-content: flex-end */ +.tutor-content-center /* align-content: center */ +.tutor-content-between /* align-content: space-between */ +.tutor-content-around /* align-content: space-around */ +.tutor-content-evenly /* align-content: space-evenly */ +``` + +#### Align Self + +```css +.tutor-self-auto /* align-self: auto */ +.tutor-self-start /* align-self: flex-start */ +.tutor-self-end /* align-self: flex-end */ +.tutor-self-center /* align-self: center */ +.tutor-self-stretch /* align-self: stretch */ +.tutor-self-baseline /* align-self: baseline */ +``` + +### Gap + +Control spacing between flex and grid items. + +```css +.tutor-gap-{size} /* gap: {size} */ +.tutor-gap-x-{size} /* column-gap: {size} */ +.tutor-gap-y-{size} /* row-gap: {size} */ +``` + +Available sizes: `none`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `18`, `19`, `20`, `21` + +### Grid + +#### Grid Template Columns + +```css +.tutor-grid-cols-1 /* grid-template-columns: repeat(1, minmax(0, 1fr)) */ +.tutor-grid-cols-2 /* grid-template-columns: repeat(2, minmax(0, 1fr)) */ +.tutor-grid-cols-3 /* grid-template-columns: repeat(3, minmax(0, 1fr)) */ +.tutor-grid-cols-4 /* grid-template-columns: repeat(4, minmax(0, 1fr)) */ +.tutor-grid-cols-5 /* grid-template-columns: repeat(5, minmax(0, 1fr)) */ +.tutor-grid-cols-6 /* grid-template-columns: repeat(6, minmax(0, 1fr)) */ +.tutor-grid-cols-12 /* grid-template-columns: repeat(12, minmax(0, 1fr)) */ +.tutor-grid-cols-none /* grid-template-columns: none */ +``` + +#### Grid Column Span + +```css +.tutor-col-auto /* grid-column: auto */ +.tutor-col-span-1 /* grid-column: span 1 / span 1 */ +.tutor-col-span-2 /* grid-column: span 2 / span 2 */ +.tutor-col-span-3 /* grid-column: span 3 / span 3 */ +.tutor-col-span-4 /* grid-column: span 4 / span 4 */ +.tutor-col-span-5 /* grid-column: span 5 / span 5 */ +.tutor-col-span-6 /* grid-column: span 6 / span 6 */ +.tutor-col-span-full /* grid-column: 1 / -1 */ +``` + +### Positioning + +```css +.tutor-static /* position: static */ +.tutor-fixed /* position: fixed */ +.tutor-absolute /* position: absolute */ +.tutor-relative /* position: relative */ +.tutor-sticky /* position: sticky */ +``` + +#### Position Values (RTL-aware) + +```css +.tutor-inset-0 /* top: 0; right: 0; bottom: 0; left: 0 */ +.tutor-inset-auto /* top: auto; right: auto; bottom: auto; left: auto */ +.tutor-top-0 /* top: 0 */ +.tutor-right-0 /* inset-inline-end: 0 */ +.tutor-bottom-0 /* bottom: 0 */ +.tutor-left-0 /* inset-inline-start: 0 */ +.tutor-top-auto /* top: auto */ +.tutor-right-auto /* inset-inline-end: auto */ +.tutor-bottom-auto /* bottom: auto */ +.tutor-left-auto /* inset-inline-start: auto */ +``` + +### Overflow + +```css +.tutor-overflow-auto /* overflow: auto */ +.tutor-overflow-hidden /* overflow: hidden */ +.tutor-overflow-visible /* overflow: visible */ +.tutor-overflow-scroll /* overflow: scroll */ +.tutor-overflow-x-auto /* overflow-x: auto */ +.tutor-overflow-y-auto /* overflow-y: auto */ +.tutor-overflow-x-hidden /* overflow-x: hidden */ +.tutor-overflow-y-hidden /* overflow-y: hidden */ +.tutor-overflow-x-visible /* overflow-x: visible */ +.tutor-overflow-y-visible /* overflow-y: visible */ +.tutor-overflow-x-scroll /* overflow-x: scroll */ +.tutor-overflow-y-scroll /* overflow-y: scroll */ +``` + +--- + +## Spacing Utilities + +### Margin (RTL-aware) + +```css +.tutor-m-{size} /* margin: {size} */ +.tutor-mt-{size} /* margin-top: {size} */ +.tutor-mr-{size} /* margin-inline-end: {size} */ +.tutor-mb-{size} /* margin-bottom: {size} */ +.tutor-ml-{size} /* margin-inline-start: {size} */ +.tutor-mx-{size} /* margin-inline: {size} */ +.tutor-my-{size} /* margin-top: {size}; margin-bottom: {size} */ +``` + +### Padding (RTL-aware) + +```css +.tutor-p-{size} /* padding: {size} */ +.tutor-pt-{size} /* padding-top: {size} */ +.tutor-pr-{size} /* padding-inline-end: {size} */ +.tutor-pb-{size} /* padding-bottom: {size} */ +.tutor-pl-{size} /* padding-inline-start: {size} */ +.tutor-px-{size} /* padding-left: {size}; padding-right: {size} */ +.tutor-py-{size} /* padding-top: {size}; padding-bottom: {size} */ +``` + +### Negative Margins + +```css +.-tutor-m-{size} /* margin: -{size} */ +.-tutor-mt-{size} /* margin-top: -{size} */ +.-tutor-mr-{size} /* margin-inline-end: -{size} */ +.-tutor-mb-{size} /* margin-bottom: -{size} */ +.-tutor-ml-{size} /* margin-inline-start: -{size} */ +.-tutor-mx-{size} /* margin-left: -{size}; margin-right: -{size} */ +.-tutor-my-{size} /* margin-top: -{size}; margin-bottom: -{size} */ +``` + +### Auto Margins + +```css +.tutor-m-auto /* margin: auto */ +.tutor-mt-auto /* margin-top: auto */ +.tutor-mr-auto /* margin-inline-end: auto */ +.tutor-mb-auto /* margin-bottom: auto */ +.tutor-ml-auto /* margin-inline-start: auto */ +.tutor-mx-auto /* margin-left: auto; margin-right: auto */ +.tutor-my-auto /* margin-top: auto; margin-bottom: auto */ +``` + +### Spacing Scale + +| Class | Value | Pixels | +| ------ | ----- | ------ | +| `none` | 0px | 0px | +| `1` | 2px | 2px | +| `2` | 4px | 4px | +| `3` | 6px | 6px | +| `4` | 8px | 8px | +| `5` | 12px | 12px | +| `6` | 16px | 16px | +| `7` | 20px | 20px | +| `8` | 24px | 24px | +| `9` | 32px | 32px | +| `10` | 40px | 40px | +| `11` | 48px | 48px | +| `12` | 56px | 56px | +| `13` | 64px | 64px | +| `14` | 72px | 72px | +| `15` | 80px | 80px | +| `16` | 88px | 88px | +| `17` | 96px | 96px | +| `18` | 104px | 104px | +| `19` | 112px | 112px | +| `20` | 120px | 120px | +| `21` | 200px | 200px | + +--- + +## Typography Utilities + +### Semantic Typography Classes + +```css +.tutor-h1 /* Heading 1: 40px/48px, Bold */ +.tutor-h2 /* Heading 2: 32px/40px, Bold */ +.tutor-h3 /* Heading 3: 24px/32px, Semi Bold */ +.tutor-h4 /* Heading 4: 20px/28px, Semi Bold */ +.tutor-h5 /* Heading 5: 18px/26px, Medium */ +.tutor-p1 /* Paragraph 1: 16px/22px */ +.tutor-p2 /* Paragraph 2: 14px/18px */ +.tutor-p3 /* Paragraph 3: 12px/18px */ +``` + +### Font Sizes + +```css +.tutor-text-h1 /* 2.5rem (40px) */ +.tutor-text-h2 /* 2rem (32px) */ +.tutor-text-h3 /* 1.5rem (24px) */ +.tutor-text-h4 /* 1.25rem (20px) */ +.tutor-text-h5 /* 1.125rem (18px) */ +.tutor-text-medium /* 1rem (16px) */ +.tutor-text-p1 /* 1rem (16px) */ +.tutor-text-small /* 0.875rem (14px) */ +.tutor-text-p2 /* 0.875rem (14px) */ +.tutor-text-tiny /* 0.75rem (12px) */ +.tutor-text-p3 /* 0.75rem (12px) */ +``` + +### Font Weights + +```css +.tutor-font-regular /* font-weight: 400 */ +.tutor-font-medium /* font-weight: 500 */ +.tutor-font-semi-strong /* font-weight: 600 */ +.tutor-font-strong /* font-weight: 700 */ +``` + +### Text Alignment (RTL-aware) + +```css +.tutor-text-left /* text-align: left (RTL: right) */ +.tutor-text-center /* text-align: center */ +.tutor-text-right /* text-align: right (RTL: left) */ +.tutor-text-justify /* text-align: justify */ +.tutor-text-start /* text-align: start */ +.tutor-text-end /* text-align: end */ +``` + +### Text Colors + +#### Basic Colors + +```css +.tutor-text-primary /* Primary text color */ +.tutor-text-secondary /* Secondary text color */ +.tutor-text-tertiary /* Tertiary text color */ +.tutor-text-disabled /* Disabled text color */ +.tutor-text-brand /* Brand text color */ +.tutor-text-inverse /* Inverse text color */ +.tutor-text-success /* Success text color */ +.tutor-text-warning /* Warning text color */ +.tutor-text-error /* Error text color */ +``` + +#### Brand Colors + +```css +.tutor-text-brand-100 /* Lightest brand color */ +.tutor-text-brand-200 +.tutor-text-brand-300 +.tutor-text-brand-400 +.tutor-text-brand-500 /* Default brand color */ +.tutor-text-brand-600 +.tutor-text-brand-700 +.tutor-text-brand-800 +.tutor-text-brand-900 +.tutor-text-brand-950 /* Darkest brand color */ +``` + +#### Gray Colors + +```css +.tutor-text-gray-1 /* White */ +.tutor-text-gray-10 +.tutor-text-gray-25 +.tutor-text-gray-50 +.tutor-text-gray-100 +.tutor-text-gray-200 +.tutor-text-gray-300 +.tutor-text-gray-400 +.tutor-text-gray-500 +.tutor-text-gray-600 +.tutor-text-gray-700 +.tutor-text-gray-750 +.tutor-text-gray-800 +.tutor-text-gray-900 +.tutor-text-gray-950 /* Darkest gray */ +``` + +### Text Decoration + +```css +.tutor-underline /* text-decoration: underline */ +.tutor-line-through /* text-decoration: line-through */ +.tutor-no-underline /* text-decoration: none */ +``` + +### Text Transform + +```css +.tutor-uppercase /* text-transform: uppercase */ +.tutor-lowercase /* text-transform: lowercase */ +.tutor-capitalize /* text-transform: capitalize */ +.tutor-normal-case /* text-transform: none */ +``` + +### Text Overflow + +```css +.tutor-truncate /* Truncate with ellipsis */ +.tutor-text-ellipsis /* text-overflow: ellipsis */ +.tutor-text-clip /* text-overflow: clip */ +``` + +### Line Clamp + +```css +.tutor-line-clamp-1 /* Clamp to 1 line */ +.tutor-line-clamp-2 /* Clamp to 2 lines */ +.tutor-line-clamp-3 /* Clamp to 3 lines */ +.tutor-line-clamp-4 /* Clamp to 4 lines */ +.tutor-line-clamp-5 /* Clamp to 5 lines */ +.tutor-line-clamp-6 /* Clamp to 6 lines */ +``` + +### White Space + +```css +.tutor-whitespace-normal /* white-space: normal */ +.tutor-whitespace-nowrap /* white-space: nowrap */ +.tutor-whitespace-pre /* white-space: pre */ +.tutor-whitespace-pre-line /* white-space: pre-line */ +.tutor-whitespace-pre-wrap /* white-space: pre-wrap */ +``` + +### Word Break + +```css +.tutor-break-normal /* overflow-wrap: normal; word-break: normal */ +.tutor-break-words /* overflow-wrap: break-word */ +.tutor-break-all /* word-break: break-all */ +``` + +### Letter Spacing + +```css +.tutor-tracking-tighter /* letter-spacing: -0.05em */ +.tutor-tracking-tight /* letter-spacing: -0.025em */ +.tutor-tracking-normal /* letter-spacing: 0em */ +.tutor-tracking-wide /* letter-spacing: 0.025em */ +.tutor-tracking-wider /* letter-spacing: 0.05em */ +.tutor-tracking-widest /* letter-spacing: 0.1em */ +``` + +### Line Height + +```css +.tutor-leading-none /* line-height: 1 */ +.tutor-leading-tight /* line-height: 1.25 */ +.tutor-leading-snug /* line-height: 1.375 */ +.tutor-leading-normal /* line-height: 1.5 */ +.tutor-leading-relaxed /* line-height: 1.625 */ +.tutor-leading-loose /* line-height: 2 */ +``` + +### List Styles + +```css +.tutor-list-none /* list-style-type: none */ +.tutor-list-disc /* list-style-type: disc */ +.tutor-list-decimal /* list-style-type: decimal */ +.tutor-list-inside /* list-style-position: inside */ +.tutor-list-outside /* list-style-position: outside */ +``` + +--- + +## Color Utilities + +### Background Colors + +#### Basic Backgrounds + +```css +.tutor-bg-transparent /* background-color: transparent */ +.tutor-bg-current /* background-color: currentColor */ +.tutor-bg-surface-base /* Base surface color */ +.tutor-bg-surface-l1 /* Level 1 surface color */ +.tutor-bg-surface-l2 /* Level 2 surface color */ +.tutor-bg-surface-elevated /* Elevated surface color */ +.tutor-bg-primary /* Primary button color */ +.tutor-bg-secondary /* Secondary button color */ +.tutor-bg-success /* Success color */ +.tutor-bg-success-light /* Light success color */ +.tutor-bg-warning /* Warning color */ +.tutor-bg-warning-light /* Light warning color */ +.tutor-bg-error /* Error color */ +.tutor-bg-error-light /* Light error color */ +``` + +#### Brand Backgrounds + +```css +.tutor-bg-brand-100 /* Lightest brand background */ +.tutor-bg-brand-200 +.tutor-bg-brand-300 +.tutor-bg-brand-400 +.tutor-bg-brand-500 /* Default brand background */ +.tutor-bg-brand-600 +.tutor-bg-brand-700 +.tutor-bg-brand-800 +.tutor-bg-brand-900 +.tutor-bg-brand-950 /* Darkest brand background */ +``` + +#### Gray Backgrounds + +```css +.tutor-bg-gray-1 /* White background */ +.tutor-bg-gray-10 +.tutor-bg-gray-25 +.tutor-bg-gray-50 +.tutor-bg-gray-100 +.tutor-bg-gray-200 +.tutor-bg-gray-300 +.tutor-bg-gray-400 +.tutor-bg-gray-500 +.tutor-bg-gray-600 +.tutor-bg-gray-700 +.tutor-bg-gray-750 +.tutor-bg-gray-800 +.tutor-bg-gray-900 +.tutor-bg-gray-950 /* Darkest gray background */ +``` + +### Border Colors + +```css +.tutor-border-transparent /* border-color: transparent */ +.tutor-border-current /* border-color: currentColor */ +.tutor-border-idle /* Default border color */ +.tutor-border-hover /* Hover border color */ +.tutor-border-focus /* Focus border color */ +.tutor-border-brand /* Brand border color */ +.tutor-border-success /* Success border color */ +.tutor-border-warning /* Warning border color */ +.tutor-border-error /* Error border color */ +``` + +#### Brand Border Colors + +```css +.tutor-border-brand-100 /* Lightest brand border */ +.tutor-border-brand-200 +.tutor-border-brand-300 +.tutor-border-brand-400 +.tutor-border-brand-500 /* Default brand border */ +.tutor-border-brand-600 +.tutor-border-brand-700 +.tutor-border-brand-800 +.tutor-border-brand-900 +.tutor-border-brand-950 /* Darkest brand border */ +``` + +### Shadows + +```css +.tutor-shadow-none /* box-shadow: none */ +.tutor-shadow-sm /* Small shadow */ +.tutor-shadow /* Default shadow */ +.tutor-shadow-md /* Medium shadow */ +.tutor-shadow-lg /* Large shadow */ +.tutor-shadow-xl /* Extra large shadow */ +``` + +### Opacity + +```css +.tutor-opacity-0 /* opacity: 0 */ +.tutor-opacity-25 /* opacity: 0.25 */ +.tutor-opacity-50 /* opacity: 0.5 */ +.tutor-opacity-75 /* opacity: 0.75 */ +.tutor-opacity-100 /* opacity: 1 */ +``` + +### Hover States + +```css +.tutor-hover-bg-primary:hover /* Hover primary background */ +.tutor-hover-bg-secondary:hover /* Hover secondary background */ +.tutor-hover-bg-surface-l2:hover /* Hover surface background */ +.tutor-hover-text-primary:hover /* Hover primary text */ +.tutor-hover-text-brand:hover /* Hover brand text */ +.tutor-hover-border-brand:hover /* Hover brand border */ +.tutor-hover-shadow-md:hover /* Hover medium shadow */ +.tutor-hover-shadow-lg:hover /* Hover large shadow */ +``` + +### Focus States + +```css +.tutor-focus-border-brand:focus /* Focus brand border */ +.tutor-focus-ring:focus /* Focus ring */ +.tutor-focus-outline-none:focus /* Remove focus outline */ +``` + +--- + +## Sizing Utilities + +### Width + +#### Basic Widths + +```css +.tutor-w-0 /* width: 0 */ +.tutor-w-auto /* width: auto */ +.tutor-w-full /* width: 100% */ +.tutor-w-screen /* width: 100vw */ +.tutor-w-min /* width: min-content */ +.tutor-w-max /* width: max-content */ +.tutor-w-fit /* width: fit-content */ +``` + +#### Fractional Widths + +```css +.tutor-w-1\/2 /* width: 50% */ +.tutor-w-1\/3 /* width: 33.333333% */ +.tutor-w-2\/3 /* width: 66.666667% */ +.tutor-w-1\/4 /* width: 25% */ +.tutor-w-2\/4 /* width: 50% */ +.tutor-w-3\/4 /* width: 75% */ +.tutor-w-1\/5 /* width: 20% */ +.tutor-w-2\/5 /* width: 40% */ +.tutor-w-3\/5 /* width: 60% */ +.tutor-w-4\/5 /* width: 80% */ +.tutor-w-1\/6 /* width: 16.666667% */ +.tutor-w-2\/6 /* width: 33.333333% */ +.tutor-w-3\/6 /* width: 50% */ +.tutor-w-4\/6 /* width: 66.666667% */ +.tutor-w-5\/6 /* width: 83.333333% */ +``` + +#### Fixed Widths (using spacing scale) + +```css +.tutor-w-{size} /* width: {size} */ +``` + +#### Additional Fixed Widths + +```css +.tutor-w-16 /* width: 64px */ +.tutor-w-20 /* width: 80px */ +.tutor-w-24 /* width: 96px */ +.tutor-w-32 /* width: 128px */ +.tutor-w-40 /* width: 160px */ +.tutor-w-48 /* width: 192px */ +.tutor-w-56 /* width: 224px */ +.tutor-w-64 /* width: 256px */ +.tutor-w-72 /* width: 288px */ +.tutor-w-80 /* width: 320px */ +.tutor-w-96 /* width: 384px */ +``` + +### Height + +#### Basic Heights + +```css +.tutor-h-0 /* height: 0 */ +.tutor-h-auto /* height: auto */ +.tutor-h-full /* height: 100% */ +.tutor-h-screen /* height: 100vh */ +.tutor-h-min /* height: min-content */ +.tutor-h-max /* height: max-content */ +.tutor-h-fit /* height: fit-content */ +``` + +#### Fixed Heights (using spacing scale) + +```css +.tutor-h-{size} /* height: {size} */ +``` + +### Min/Max Width + +```css +.tutor-min-w-0 /* min-width: 0 */ +.tutor-min-w-full /* min-width: 100% */ +.tutor-min-w-min /* min-width: min-content */ +.tutor-min-w-max /* min-width: max-content */ +.tutor-min-w-fit /* min-width: fit-content */ + +.tutor-max-w-0 /* max-width: 0 */ +.tutor-max-w-none /* max-width: none */ +.tutor-max-w-xs /* max-width: 320px */ +.tutor-max-w-sm /* max-width: 384px */ +.tutor-max-w-md /* max-width: 448px */ +.tutor-max-w-lg /* max-width: 512px */ +.tutor-max-w-xl /* max-width: 576px */ +.tutor-max-w-2xl /* max-width: 672px */ +.tutor-max-w-3xl /* max-width: 768px */ +.tutor-max-w-4xl /* max-width: 896px */ +.tutor-max-w-5xl /* max-width: 1024px */ +.tutor-max-w-6xl /* max-width: 1152px */ +.tutor-max-w-7xl /* max-width: 1280px */ +.tutor-max-w-full /* max-width: 100% */ +.tutor-max-w-prose /* max-width: 65ch */ +``` + +### Min/Max Height + +```css +.tutor-min-h-0 /* min-height: 0 */ +.tutor-min-h-full /* min-height: 100% */ +.tutor-min-h-screen /* min-height: 100vh */ +.tutor-min-h-min /* min-height: min-content */ +.tutor-min-h-max /* min-height: max-content */ +.tutor-min-h-fit /* min-height: fit-content */ + +.tutor-max-h-0 /* max-height: 0 */ +.tutor-max-h-full /* max-height: 100% */ +.tutor-max-h-screen /* max-height: 100vh */ +.tutor-max-h-min /* max-height: min-content */ +.tutor-max-h-max /* max-height: max-content */ +.tutor-max-h-fit /* max-height: fit-content */ +``` + +--- + +## Border Utilities + +### Border Width & Style + +#### Basic Borders (with smart defaults) + +```css +.tutor-border /* border: 1px solid var(--tutor-border-idle) */ +.tutor-border-0 /* border: none */ +.tutor-border-2 /* border: 2px solid var(--tutor-border-idle) */ +.tutor-border-4 /* border: 4px solid var(--tutor-border-idle) */ +.tutor-border-8 /* border: 8px solid var(--tutor-border-idle) */ +``` + +#### Directional Borders (RTL-aware) + +```css +.tutor-border-t /* border-top: 1px solid var(--tutor-border-idle) */ +.tutor-border-r /* border-inline-end: 1px solid var(--tutor-border-idle) */ +.tutor-border-b /* border-bottom: 1px solid var(--tutor-border-idle) */ +.tutor-border-l /* border-inline-start: 1px solid var(--tutor-border-idle) */ + +.tutor-border-t-0 /* border-top: none */ +.tutor-border-r-0 /* border-inline-end: none */ +.tutor-border-b-0 /* border-bottom: none */ +.tutor-border-l-0 /* border-inline-start: none */ + +.tutor-border-t-2 /* border-top: 2px solid var(--tutor-border-idle) */ +.tutor-border-r-2 /* border-inline-end: 2px solid var(--tutor-border-idle) */ +.tutor-border-b-2 /* border-bottom: 2px solid var(--tutor-border-idle) */ +.tutor-border-l-2 /* border-inline-start: 2px solid var(--tutor-border-idle) */ +``` + +#### Border Styles + +```css +.tutor-border-solid /* border-style: solid */ +.tutor-border-dashed /* border-style: dashed */ +.tutor-border-dotted /* border-style: dotted */ +.tutor-border-double /* border-style: double */ +.tutor-border-none /* border-style: none */ +``` + +### Border Radius (RTL-aware) + +#### All Corners + +```css +.tutor-rounded-{size} /* border-radius: {size} */ +``` + +#### Directional Radius + +```css +.tutor-rounded-t-{size} /* border-start-start-radius & border-start-end-radius */ +.tutor-rounded-b-{size} /* border-end-end-radius & border-end-start-radius */ +.tutor-rounded-r-{size} /* border-start-end-radius & border-end-end-radius */ +.tutor-rounded-l-{size} /* border-start-start-radius & border-end-start-radius */ +``` + +#### Individual Corners + +```css +.tutor-rounded-tl-{size} /* border-start-start-radius */ +.tutor-rounded-tr-{size} /* border-start-end-radius */ +.tutor-rounded-br-{size} /* border-end-end-radius */ +.tutor-rounded-bl-{size} /* border-end-start-radius */ +``` + +### Semantic Border Utilities + +```css +.tutor-border-card /* Card border with radius */ +.tutor-border-input /* Input border with focus state */ +.tutor-border-divider /* Bottom border divider */ +.tutor-border-accent /* Accent border (inline-start) */ +``` + +--- + +## RTL Utilities + +### Logical Direction Utilities + +```css +.tutor-text-start /* text-align: start */ +.tutor-text-end /* text-align: end */ +.tutor-float-start /* float: inline-start */ +.tutor-float-end /* float: inline-end */ +``` + +### Logical Spacing + +#### Margin + +```css +.tutor-ms-{size} /* margin-inline-start: {size} */ +.tutor-me-{size} /* margin-inline-end: {size} */ +``` + +#### Padding + +```css +.tutor-ps-{size} /* padding-inline-start: {size} */ +.tutor-pe-{size} /* padding-inline-end: {size} */ +``` + +### Logical Positioning + +```css +.tutor-start-0 /* inset-inline-start: 0 */ +.tutor-end-0 /* inset-inline-end: 0 */ +``` + +### Logical Borders + +```css +.tutor-border-start /* border-inline-start: 1px solid var(--tutor-border-idle) */ +.tutor-border-end /* border-inline-end: 1px solid var(--tutor-border-idle) */ +``` + +### Component Utilities + +```css +.tutor-icon-start /* Icon at start position */ +.tutor-icon-end /* Icon at end position */ +.tutor-dropdown-start /* Dropdown positioned at start */ +.tutor-dropdown-end /* Dropdown positioned at end */ +.tutor-sidebar-start /* Sidebar positioned at start */ +.tutor-sidebar-end /* Sidebar positioned at end */ +.tutor-toast-start /* Toast positioned at start */ +.tutor-toast-end /* Toast positioned at end */ +``` + +### Language-Specific Classes + +```css +.tutor-lang-ar /* Arabic: direction: rtl; text-align: right */ +.tutor-lang-he /* Hebrew: direction: rtl; text-align: right */ +.tutor-lang-fa /* Persian: direction: rtl; text-align: right */ +.tutor-lang-ur /* Urdu: direction: rtl; text-align: right */ +.tutor-lang-en /* English: direction: ltr; text-align: left */ +.tutor-lang-es /* Spanish: direction: ltr; text-align: left */ +.tutor-lang-fr /* French: direction: ltr; text-align: left */ +.tutor-lang-de /* German: direction: ltr; text-align: left */ +``` + +--- + +## Z-Index Utilities + +```css +.tutor-z-{level} /* z-index: {level} */ +``` + +Available z-index levels are defined in the design tokens. + +--- + +## Responsive Design + +All utility classes support responsive variants using breakpoint prefixes: + +### Breakpoint Prefixes + +- `tutor-sm-*` - Small screens and up +- `tutor-md-*` - Medium screens and up +- `tutor-lg-*` - Large screens and up +- `tutor-xl-*` - Extra large screens and up +- `tutor-2xl-*` - 2X large screens and up + +### Examples + +```css +.tutor-md-flex /* display: flex on medium screens and up */ +.tutor-lg-grid-cols-3 /* 3 columns on large screens and up */ +.tutor-sm-text-center /* Center text on small screens and up */ +.tutor-xl-p-8 /* Padding 8 on extra large screens and up */ +``` + +--- + +## Design Tokens + +### Spacing Scale + +The spacing system uses a consistent scale from 0px to 200px: + +| Token | Value | Usage | +| ------ | ----- | --------------------- | +| `none` | 0px | No spacing | +| `1` | 2px | Minimal spacing | +| `2` | 4px | Very small spacing | +| `3` | 6px | Small spacing | +| `4` | 8px | Small spacing | +| `5` | 12px | Medium-small spacing | +| `6` | 16px | Medium spacing (base) | +| `7` | 20px | Medium spacing | +| `8` | 24px | Medium-large spacing | +| `9` | 32px | Large spacing | +| `10` | 40px | Large spacing | +| `11` | 48px | Extra large spacing | +| `12` | 56px | Extra large spacing | +| `13` | 64px | XXL spacing | +| `14` | 72px | XXL spacing | +| `15` | 80px | XXXL spacing | +| `16` | 88px | XXXL spacing | +| `17` | 96px | XXXL spacing | +| `18` | 104px | XXXL spacing | +| `19` | 112px | XXXL spacing | +| `20` | 120px | XXXL spacing | +| `21` | 200px | Maximum spacing | + +### Typography Scale + +| Token | Size | Line Height | Usage | +| ----------- | ---- | ----------- | ------------------- | +| `h1` | 40px | 48px | Main headings | +| `h2` | 32px | 40px | Section headings | +| `h3` | 24px | 32px | Subsection headings | +| `h4` | 20px | 28px | Component headings | +| `h5` | 18px | 26px | Small headings | +| `medium/p1` | 16px | 22px | Body text | +| `small/p2` | 14px | 18px | Secondary text | +| `tiny/p3` | 12px | 18px | Caption text | + +### Color Palette + +#### Brand Colors + +- `brand-100` to `brand-950` - Primary brand colors from lightest to darkest +- `brand-500` - Default brand color + +#### Gray Colors + +- `gray-1` to `gray-950` - Neutral colors from white to near-black +- `gray-600` - Default text color + +#### Semantic Colors + +- `success-25` to `success-950` - Success states +- `warning-25` to `warning-950` - Warning states +- `error-25` to `error-950` - Error states +- `exception-1` to `exception-6` - Special accent colors + +--- + +## Best Practices + +### 1. Use Semantic Classes First + +```html + +

Main Heading

+

Body text

+ + +

Main Heading

+``` + +### 2. Leverage RTL-Aware Classes + +```html + +
Content with left margin and left-aligned text
+ + +
Content with start margin and start-aligned text
+``` + +### 3. Use Responsive Variants + +```html + +
+
Item 1
+
Item 2
+
Item 3
+
+``` + +### 4. Combine Utilities Effectively + +```html + +
+

Card Title

+

Card content

+
+``` + +### 5. Use Consistent Spacing + +```html + +
+

Article Title

+

First paragraph

+

Second paragraph

+
+``` + +--- + +## Migration Guide + +### From Custom CSS to Utilities + +#### Before + +```css +.my-component { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background-color: white; + border: 1px solid #e5e5e5; + border-radius: 8px; +} +``` + +#### After + +```html +
+ +
+``` + +### RTL Migration + +#### Before (Physical Properties) + +```css +.component { + margin-left: 16px; + text-align: left; + border-left: 2px solid blue; +} +``` + +#### After (Logical Properties) + +```html +
+ +
+``` + +This utility system provides a comprehensive, consistent, and maintainable approach to styling that scales with your design system and supports modern web standards including RTL languages and responsive design. diff --git a/assets/core/docs/utility-examples.html b/assets/core/docs/utility-examples.html new file mode 100644 index 0000000000..70f5f0db9d --- /dev/null +++ b/assets/core/docs/utility-examples.html @@ -0,0 +1,862 @@ + + + + + + Tutor CSS Utility Classes - Interactive Examples + + + + + +
+
+

Tutor CSS Utility Classes

+

Interactive examples of all utility classes available in the Tutor design system. Each example shows the visual result and the corresponding HTML code.

+
+ + +
+

Layout Utilities

+ +

Flexbox Layout

+
+
+
+
Item 1
+
Item 2
+
Item 3
+
+
+
+<div class="tutor-flex tutor-items-center tutor-justify-between tutor-p-4 tutor-bg-brand-100 tutor-rounded-lg"> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg">Item 1</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg">Item 2</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg">Item 3</div> +</div> +
+
+ +

Flex Direction

+
+
+
+
Item 1
+
Item 2
+
Item 3
+
+
+
+<div class="tutor-flex tutor-flex-col tutor-gap-4 tutor-p-4 tutor-bg-brand-100 tutor-rounded-lg"> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">Item 1</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">Item 2</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">Item 3</div> +</div> +
+
+ +

Grid Layout

+
+
+
+
1
+
2
+
3
+
4
+
5
+
6
+
+
+
+<div class="tutor-grid tutor-grid-cols-3 tutor-gap-4 tutor-p-4 tutor-bg-brand-100 tutor-rounded-lg"> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">1</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">2</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">3</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">4</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">5</div> + <div class="tutor-p-4 tutor-bg-brand-500 tutor-rounded-lg tutor-text-center">6</div> +</div> +
+
+
+ + +
+

Spacing Utilities

+ +

Padding Scale

+
+
+
+
+
tutor-p-4 (8px)
+
+
+
tutor-p-6 (16px)
+
+
+
tutor-p-8 (24px)
+
+
+
+
+<div class="tutor-p-4">Padding 8px</div> +<div class="tutor-p-6">Padding 16px</div> +<div class="tutor-p-8">Padding 24px</div> +
+
+ +

Margin Examples

+
+
+
+
tutor-m-4
+
tutor-mb-4
+
tutor-mt-6
+
+
+
+<div class="tutor-m-4">All margins 8px</div> +<div class="tutor-mb-4">Bottom margin 8px</div> +<div class="tutor-mt-6">Top margin 16px</div> +
+
+ +

Spacing Scale Reference

+
+
+
+
tutor-*-1
2px
+
tutor-*-2
4px
+
tutor-*-3
6px
+
tutor-*-4
8px
+
tutor-*-5
12px
+
tutor-*-6
16px
+
tutor-*-7
20px
+
tutor-*-8
24px
+
tutor-*-9
32px
+
tutor-*-10
40px
+
tutor-*-11
48px
+
tutor-*-12
56px
+
+
+
+/* Available spacing values */ +tutor-p-{size} /* padding */ +tutor-m-{size} /* margin */ +tutor-px-{size} /* horizontal padding */ +tutor-py-{size} /* vertical padding */ +tutor-mx-{size} /* horizontal margin */ +tutor-my-{size} /* vertical margin */ +
+
+
+ + +
+

Typography Utilities

+ +

Semantic Typography

+
+
+
+

tutor-h1 - Main Heading (40px)

+

tutor-h2 - Section Heading (32px)

+

tutor-h3 - Subsection Heading (24px)

+

tutor-h4 - Component Heading (20px)

+
tutor-h5 - Small Heading (18px)
+

tutor-p1 - Body text (16px)

+

tutor-p2 - Secondary text (14px)

+

tutor-p3 - Caption text (12px)

+
+
+
+<h1 class="tutor-h1">Main Heading</h1> +<h2 class="tutor-h2">Section Heading</h2> +<h3 class="tutor-h3">Subsection Heading</h3> +<h4 class="tutor-h4">Component Heading</h4> +<h5 class="tutor-h5">Small Heading</h5> +<p class="tutor-p1">Body text</p> +<p class="tutor-p2">Secondary text</p> +<p class="tutor-p3">Caption text</p> +
+
+ +

Font Weights

+
+
+
+

tutor-font-regular (400) - Regular text

+

tutor-font-medium (500) - Medium text

+

tutor-font-semi-strong (600) - Semi bold text

+

tutor-font-strong (700) - Bold text

+
+
+
+<p class="tutor-font-regular">Regular text</p> +<p class="tutor-font-medium">Medium text</p> +<p class="tutor-font-semi-strong">Semi bold text</p> +<p class="tutor-font-strong">Bold text</p> +
+
+ +

Text Alignment

+
+
+
+

tutor-text-left - Left aligned text

+

tutor-text-center - Center aligned text

+

tutor-text-right - Right aligned text

+
+
+
+<p class="tutor-text-left">Left aligned text</p> +<p class="tutor-text-center">Center aligned text</p> +<p class="tutor-text-right">Right aligned text</p> +
+
+
+ + +
+

Color Utilities

+ +

Brand Colors

+
+
+
+
+
+ brand-100
#f6f8fe +
+
+
+ brand-200
#e4ebfc +
+
+
+ brand-300
#dbe4fa +
+
+
+ brand-500
#4979e8 +
+
+
+ brand-600
#3e64de +
+
+
+ brand-700
#2b49ca +
+
+
+
+<div class="tutor-bg-brand-100">Light brand background</div> +<div class="tutor-bg-brand-500">Default brand background</div> +<div class="tutor-text-brand-500">Brand colored text</div> +<div class="tutor-border-brand-500">Brand colored border</div> +
+
+ +

Semantic Colors

+
+
+
+
+
+
Success
+
tutor-bg-success-100
+
+
+
+
+
Warning
+
tutor-bg-warning-100
+
+
+
+
+
Error
+
tutor-bg-error-100
+
+
+
+
+
+<div class="tutor-bg-success-100 tutor-text-success-600">Success message</div> +<div class="tutor-bg-warning-100 tutor-text-warning-600">Warning message</div> +<div class="tutor-bg-error-100 tutor-text-error-600">Error message</div> +
+
+ +

Text Colors

+
+
+
+

tutor-text-primary - Primary text color

+

tutor-text-secondary - Secondary text color

+

tutor-text-disabled - Disabled text color

+

tutor-text-brand-500 - Brand text color

+

tutor-text-success-600 - Success text color

+

tutor-text-error-600 - Error text color

+
+
+
+<p class="tutor-text-primary">Primary text</p> +<p class="tutor-text-secondary">Secondary text</p> +<p class="tutor-text-disabled">Disabled text</p> +<p class="tutor-text-brand-500">Brand text</p> +<p class="tutor-text-success-600">Success text</p> +<p class="tutor-text-error-600">Error text</p> +
+
+
+ + +
+

Sizing Utilities

+ +

Width Utilities

+
+
+
+
tutor-w-1/4 (25%)
+
tutor-w-1/2 (50%)
+
tutor-w-3/4 (75%)
+
tutor-w-full (100%)
+
+
+
+<div class="tutor-w-1/4">25% width</div> +<div class="tutor-w-1/2">50% width</div> +<div class="tutor-w-3/4">75% width</div> +<div class="tutor-w-full">100% width</div> +
+
+ +

Height Utilities

+
+
+
+
h-8
+
h-16
+
h-24
+
+
+
+<div class="tutor-h-8">32px height</div> +<div class="tutor-h-16">64px height</div> +<div class="tutor-h-24">96px height</div> +
+
+
+ + +
+

Border Utilities

+ +

Border Styles

+
+
+
+
+
tutor-border
+
+
+
tutor-border-brand-500
+
+
+
border-dashed
+
+
+
+
+<div class="tutor-border">Default border</div> +<div class="tutor-border tutor-border-brand-500">Brand colored border</div> +<div class="tutor-border-dashed">Dashed border</div> +
+
+ +

Border Radius

+
+
+
+
+
tutor-rounded-sm
+
+
+
tutor-rounded-lg
+
+
+
tutor-rounded-full
+
+
+
+
+<div class="tutor-rounded-sm">Small radius</div> +<div class="tutor-rounded-lg">Large radius</div> +<div class="tutor-rounded-full">Full radius</div> +
+
+ +

Shadows

+
+
+
+
+
tutor-shadow-sm
+
+
+
tutor-shadow-md
+
+
+
tutor-shadow-lg
+
+
+
+
+<div class="tutor-shadow-sm">Small shadow</div> +<div class="tutor-shadow-md">Medium shadow</div> +<div class="tutor-shadow-lg">Large shadow</div> +
+
+
+ + +
+

Component Examples

+ +

Card Component

+
+
+
+

Card Title

+

This is a card component built entirely with utility classes. It demonstrates how utilities can be combined to create complex components.

+ +
+
+
+<div class="tutor-bg-surface-l1 tutor-border tutor-rounded-lg tutor-p-6 tutor-shadow-sm"> + <h3 class="tutor-text-lg tutor-font-semibold tutor-mb-4">Card Title</h3> + <p class="tutor-text-sm tutor-text-secondary tutor-mb-4">Card content</p> + <button class="tutor-bg-brand-500 tutor-text-white tutor-p-4 tutor-rounded-lg">Action</button> +</div> +
+
+ +

Navigation Bar

+
+
+ +
+
+<nav class="tutor-flex tutor-items-center tutor-justify-between tutor-p-4 tutor-bg-surface-l1 tutor-border tutor-rounded-lg"> + <div class="tutor-text-lg tutor-font-semibold tutor-text-brand-500">Brand</div> + <div class="tutor-flex tutor-gap-6"> + <a href="#" class="tutor-text-sm tutor-text-secondary">Home</a> + <a href="#" class="tutor-text-sm tutor-text-secondary">About</a> + <a href="#" class="tutor-text-sm tutor-text-secondary">Contact</a> + </div> +</nav> +
+
+ +

Alert Messages

+
+
+
+
+
Success!
+
Your changes have been saved successfully.
+
+
+
Warning!
+
Please review your input before proceeding.
+
+
+
Error!
+
Something went wrong. Please try again.
+
+
+
+
+<div class="tutor-bg-success-100 tutor-border tutor-rounded-lg tutor-p-4"> + <div class="tutor-text-success-600 tutor-font-medium">Success!</div> + <div class="tutor-text-secondary">Your changes have been saved.</div> +</div> + +<div class="tutor-bg-warning-100 tutor-border tutor-rounded-lg tutor-p-4"> + <div class="tutor-text-warning-600 tutor-font-medium">Warning!</div> + <div class="tutor-text-secondary">Please review your input.</div> +</div> + +<div class="tutor-bg-error-100 tutor-border tutor-rounded-lg tutor-p-4"> + <div class="tutor-text-error-600 tutor-font-medium">Error!</div> + <div class="tutor-text-secondary">Something went wrong.</div> +</div> +
+
+
+ + +
+

Responsive Design

+ +

Responsive Grid

+
+
+
+
Item 1
+
Item 2
+
Item 3
+
Item 4
+
+

+ Note: This grid would be 1 column on mobile, 2 columns on tablet, and 4 columns on desktop using responsive classes. +

+
+
+<div class="tutor-grid tutor-grid-cols-1 tutor-md-grid-cols-2 tutor-lg-grid-cols-4 tutor-gap-4"> + <div class="tutor-bg-brand-100 tutor-p-4 tutor-rounded-lg">Item 1</div> + <div class="tutor-bg-brand-100 tutor-p-4 tutor-rounded-lg">Item 2</div> + <div class="tutor-bg-brand-100 tutor-p-4 tutor-rounded-lg">Item 3</div> + <div class="tutor-bg-brand-100 tutor-p-4 tutor-rounded-lg">Item 4</div> +</div> +
+
+ +

Responsive Typography

+
+
+
+

Responsive Heading

+

This heading would be smaller on mobile (tutor-text-lg) and larger on desktop (tutor-lg-text-2xl). The text size adapts to screen size for optimal readability.

+
+
+
+<h2 class="tutor-text-lg tutor-lg-text-2xl">Responsive Heading</h2> +<p class="tutor-text-sm tutor-md-text-base">Responsive paragraph text</p> +
+
+ +

Responsive Spacing

+
+
+
+
+ Responsive padding: small on mobile, large on desktop +
+
+
+
+<div class="tutor-p-4 tutor-lg-p-8"> + Responsive padding +</div> +
+
+ +

Breakpoint Reference

+
+
+
+
Default
All screen sizes
+
tutor-sm-*
≥640px (Small)
+
tutor-md-*
≥768px (Medium)
+
tutor-lg-*
≥1024px (Large)
+
tutor-xl-*
≥1280px (XL)
+
tutor-2xl-*
≥1536px (2XL)
+
+
+
+/* Mobile-first responsive design */ +tutor-p-4 /* padding on all screens */ +tutor-md-p-6 /* padding on medium screens and up */ +tutor-lg-p-8 /* padding on large screens and up */ + +tutor-grid-cols-1 /* 1 column on mobile */ +tutor-md-grid-cols-2 /* 2 columns on tablet */ +tutor-lg-grid-cols-3 /* 3 columns on desktop */ +
+
+
+ + +
+

Quick Reference

+
+
+ Layout
+ tutor-flex, tutor-grid
+ tutor-items-center
+ tutor-justify-between +
+
+ Spacing
+ tutor-p-{1-21}
+ tutor-m-{1-21}
+ tutor-gap-{1-21} +
+
+ Typography
+ tutor-h1, tutor-h2
+ tutor-p1, tutor-p2
+ tutor-text-{size} +
+
+ Colors
+ tutor-bg-brand-{100-950}
+ tutor-text-brand-{100-950}
+ tutor-border-brand-{100-950} +
+
+ Sizing
+ tutor-w-{size}
+ tutor-h-{size}
+ tutor-max-w-{size} +
+
+ Borders
+ tutor-border
+ tutor-rounded-{size}
+ tutor-shadow-{size} +
+
+ +
+

💡 Pro Tip: All utilities support responsive variants (tutor-md-*, tutor-lg-*) and are RTL-aware by default!

+
+
+
+ + + + \ No newline at end of file diff --git a/assets/core/scss/components/_accordion.scss b/assets/core/scss/components/_accordion.scss new file mode 100644 index 0000000000..b938eafa99 --- /dev/null +++ b/assets/core/scss/components/_accordion.scss @@ -0,0 +1,71 @@ +@use '../mixins' as *; +@use '../tokens' as *; + +.tutor-accordion { + overflow: visible; + background-color: transparent; + @include tutor-flex(column, stretch, start); + gap: $tutor-spacing-6; + + &-item { + background-color: $tutor-surface-l1; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-lg; + overflow: hidden; + } + + &-header { + @include tutor-flex(row, start, space-between); + width: 100%; + padding: $tutor-spacing-5 $tutor-spacing-6; + background: none; + border: none; + text-align: left; + cursor: pointer; + @include tutor-transition(background-color); + + &:hover, + &:focus { + background-color: $tutor-surface-l2; + outline: none; + } + } + + &-title { + margin: 0; + @include tutor-typography('medium', 'medium', 'primary', 'heading'); + } + + &-icon { + @include tutor-flex-center(); + flex-shrink: 0; + color: $tutor-icon-idle; + @include tutor-transition(transform); + + .tutor-accordion-header[aria-expanded='true'] & { + transform: rotate(180deg); + color: $tutor-text-primary; + } + + // RTL support for icon rotation + [dir='rtl'] .tutor-accordion-header[aria-expanded='true'] & { + transform: rotate(180deg) scaleX(-1); + } + } + + &-content { + overflow: hidden; + } + + &-body { + padding: $tutor-spacing-2 $tutor-spacing-6 $tutor-spacing-5; + + p:first-child { + margin-top: 0; + } + + p:last-child { + margin-bottom: 0; + } + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_alert.scss b/assets/core/scss/components/_alert.scss new file mode 100644 index 0000000000..3665780cb0 --- /dev/null +++ b/assets/core/scss/components/_alert.scss @@ -0,0 +1,56 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-alert { + @include tutor-flex(row, center, space-between); + gap: $tutor-spacing-5; + padding: $tutor-spacing-4; + border-radius: $tutor-radius-sm; + min-height: 48px; + + .tutor-alert-content { + @include tutor-flex(row, start); + gap: $tutor-spacing-3; + } + + .tutor-alert-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: $tutor-icon-idle; + + svg { + width: 20px; + height: 20px; + } + } + + .tutor-alert-text { + @include tutor-typography('tiny', 'regular', 'secondary'); + } + + .tutor-alert-action { + flex-shrink: 0; + } + + &.tutor-alert-default { + background-color: $tutor-surface-l1-hover; + } + + &.tutor-alert-info { + background-color: $tutor-surface-brand-quaternary; + } + + &.tutor-alert-success { + background-color: $tutor-surface-success; + } + + &.tutor-alert-warning { + background-color: $tutor-surface-warning-hover; + } + + &.tutor-alert-error { + background-color: $tutor-surface-critical; + } +} diff --git a/assets/core/scss/components/_attachment-card.scss b/assets/core/scss/components/_attachment-card.scss new file mode 100644 index 0000000000..d0cb601508 --- /dev/null +++ b/assets/core/scss/components/_attachment-card.scss @@ -0,0 +1,64 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-attachment-card { + @include tutor-flex(row, center, flex-start); + width: 100%; + gap: $tutor-spacing-5; + padding: $tutor-spacing-4 $tutor-spacing-5; + @include tutor-transition(background-color); + position: relative; + + &:hover, + &:focus-within { + background-color: $tutor-surface-l1-hover; + } + + &-icon { + @include tutor-flex(row, center, center); + width: 48px; + height: 48px; + border-radius: $tutor-radius-sm; + background-color: $tutor-surface-l2; + flex-shrink: 0; + + svg { + color: $tutor-icon-idle; + } + + &-loading { + svg { + @include tutor-spin(0.9s); + } + } + } + + &-body { + @include tutor-flex(column); + gap: $tutor-spacing-1; + flex: 1 1 auto; + min-width: 0; + } + + &-title { + @include tutor-typography('small', 'medium', 'primary'); + @include tutor-text-truncate; + } + + &-meta { + @include tutor-typography('tiny', 'regular', 'secondary', 'body'); + } + + @include tutor-breakpoint-up(md) { + &-actions { + opacity: 0; + @include tutor-transition((opacity, visibility)); + } + + &:hover &-actions, + &:focus-within &-actions, + &-show-actions &-actions { + opacity: 1; + } + } +} diff --git a/assets/core/scss/components/_avatar.scss b/assets/core/scss/components/_avatar.scss new file mode 100644 index 0000000000..1a4ae42820 --- /dev/null +++ b/assets/core/scss/components/_avatar.scss @@ -0,0 +1,47 @@ +// Avatar scss +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-avatar { + @include tutor-avatar-base; + @include tutor-avatar-size(48); +} + +.tutor-avatar-image { + @include tutor-avatar-image; +} + +.tutor-avatar-initials { + @include tutor-avatar-initials; +} + +// Avatar sizes +@each $size, $config in $tutor-avatar-sizes { + .tutor-avatar-#{$size} { + @include tutor-avatar-size($size); + } +} + +// Avatar variants +.tutor-avatar-square { + @include tutor-avatar-variant(square); +} + +.tutor-avatar-icon { + @include tutor-avatar-variant(icon); +} + +.tutor-avatar-border { + @include tutor-avatar-variant(border); +} + +// Responsive Avatar sizes +@each $breakpoint, $value in $tutor-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + @each $size, $config in $tutor-avatar-sizes { + .tutor-avatar-#{$breakpoint}-#{$size} { + @include tutor-avatar-size($size); + } + } + } +} diff --git a/assets/core/scss/components/_badge.scss b/assets/core/scss/components/_badge.scss new file mode 100644 index 0000000000..8a624a202a --- /dev/null +++ b/assets/core/scss/components/_badge.scss @@ -0,0 +1,38 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-badge { + @include tutor-badge-base; + + &-primary { + @include tutor-badge-variant(primary); + } + + &-info { + @include tutor-badge-variant(info); + } + + &-warning { + @include tutor-badge-variant(warning); + } + + &-success { + @include tutor-badge-variant(success); + } + + &-success-solid { + @include tutor-badge-variant(success-solid); + } + + &-error { + @include tutor-badge-variant(error); + } + + &-highlight { + @include tutor-badge-variant(highlight); + } + + &-disabled { + @include tutor-badge-variant(disabled); + } +} diff --git a/assets/core/scss/components/_button.scss b/assets/core/scss/components/_button.scss new file mode 100644 index 0000000000..5b852b49ad --- /dev/null +++ b/assets/core/scss/components/_button.scss @@ -0,0 +1,160 @@ +// Button Component +@use '../tokens' as *; +@use '../mixins' as *; + +button.tutor-btn, +a.tutor-btn, +.tutor-btn { + @include tutor-button-base; + @include tutor-button-size(medium); +} + +// Button variants +button.tutor-btn-primary, +a.tutor-btn-primary, +.tutor-btn-primary { + @include tutor-button-variant(primary); +} + +button.tutor-btn-primary-soft, +a.tutor-btn-primary-soft, +.tutor-btn-primary-soft { + @include tutor-button-variant(primary-soft); +} + +button.tutor-btn-destructive, +a.tutor-btn-destructive, +.tutor-btn-destructive { + @include tutor-button-variant(destructive); +} + +button.tutor-btn-destructive-soft, +a.tutor-btn-destructive-soft, +.tutor-btn-destructive-soft { + @include tutor-button-variant(destructive-soft); +} + +button.tutor-btn-secondary, +a.tutor-btn-secondary, +.tutor-btn-secondary { + @include tutor-button-variant(secondary); +} + +button.tutor-btn-outline, +a.tutor-btn-outline, +.tutor-btn-outline { + @include tutor-button-variant(outline); +} + +button.tutor-btn-ghost, +a.tutor-btn-ghost, +.tutor-btn-ghost { + @include tutor-button-variant(ghost); +} + +button.tutor-btn-ghost-brand, +a.tutor-btn-ghost-brand, +.tutor-btn-ghost-brand { + @include tutor-button-variant(ghost-brand); +} + +button.tutor-btn-link, +a.tutor-btn-link, +.tutor-btn-link { + @include tutor-button-variant(link); + box-shadow: none +} + +button.tutor-btn-link-gray, +a.tutor-btn-link-gray, +.tutor-btn-link-gray { + @include tutor-button-variant(link-gray); +} + +button.tutor-btn-link-destructive, +a.tutor-btn-link-destructive, +.tutor-btn-link-destructive { + @include tutor-button-variant(link-destructive); +} + +// Button sizes +button.tutor-btn-x-small, +a.tutor-btn-x-small, +.tutor-btn-x-small { + @include tutor-button-size(x-small); + + &.tutor-btn-loading::before { + width: 12px; + height: 12px; + } +} + +button.tutor-btn-small, +a.tutor-btn-small, +.tutor-btn-small { + @include tutor-button-size(small); + + &.tutor-btn-loading::before { + width: 14px; + height: 14px; + } +} + +button.tutor-btn-medium, +a.tutor-btn-medium, +.tutor-btn-medium { + @include tutor-button-size(medium); +} + +button.tutor-btn-large, +a.tutor-btn-large, +.tutor-btn-large { + @include tutor-button-size(large); + + &.tutor-btn-loading::before { + width: 20px; + height: 20px; + } +} + +// Button states +.tutor-btn-loading { + @include tutor-button-loading; +} + +// Button with full width +button.tutor-btn-block, +a.tutor-btn-block, +.tutor-btn-block { + @include tutor-button-block(); +} + +// Button group +.tutor-btn-group { + @include tutor-button-group(horizontal); +} + +// Button group vertical +.tutor-btn-group-vertical { + @include tutor-button-group(vertical); +} + +// Icon-only button +button.tutor-btn-icon, +a.tutor-btn-icon, +.tutor-btn-icon { + flex-shrink: 0; + @include tutor-button-icon-size(medium); + + &.tutor-btn-x-small { + @include tutor-button-icon-size(x-small); + } + + &.tutor-btn-small { + @include tutor-button-icon-size(small); + } + + &.tutor-btn-large { + @include tutor-button-icon-size(large); + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_calendar.scss b/assets/core/scss/components/_calendar.scss new file mode 100644 index 0000000000..23d30bad70 --- /dev/null +++ b/assets/core/scss/components/_calendar.scss @@ -0,0 +1,193 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-vc-calendar { + z-index: calc($tutor-z-highest + 1); + background-color: $tutor-surface-l1 !important; + border-radius: $tutor-radius-md !important; + + [data-vc='month'], + [data-vc='year'] { + @include tutor-typography('small', 'medium', 'primary'); + + &:hover { + color: $tutor-text-primary !important; + } + } + + [data-vc-week-day] { + @include tutor-typography('tiny', 'regular', 'secondary'); + color: $tutor-text-secondary !important; + } + + .vc-months__month.vc-months__month, + .vc-years__year.vc-years__year { + @include tutor-button-base(); + @include tutor-button-variant('ghost'); + color: $tutor-text-primary; + } + + [data-vc-months-month-selected], + [data-vc-years-year-selected] { + background-color: $tutor-surface-dark !important; + color: $tutor-text-primary-inverse !important; + } + + button { + box-shadow: none; + transition: none; + @include tutor-transition((background-color, color)); + } + + button[data-vc-arrow], + button[data-vc='month'], + button[data-vc='year'] { + background-color: transparent; + color: $tutor-text-primary; + + &:hover, + &:focus { + background-color: transparent; + color: $tutor-text-primary; + } + } + + button.vc-date__btn { + background-color: transparent; + font-size: $tutor-font-size-small; + color: $tutor-text-primary; + border-radius: $tutor-radius-md; + + &:hover { + background-color: $tutor-button-ghost-hover; + } + } + + .vc-date[data-vc-date-today] .vc-date__btn { + background-color: $tutor-button-ghost-hover !important; + color: $tutor-text-primary !important; + } + + .vc-date[data-vc-date-hover] .vc-date__btn { + background-color: $tutor-button-ghost-hover !important; + } + + .vc-date[data-vc-date-hover='last'] .vc-date__btn { + background-color: $tutor-button-secondary-hover !important; + } + + .vc-date[data-vc-date-selected] .vc-date__btn, + .vc-date[data-vc-date-selected] .vc-date__btn:hover { + background-color: $tutor-surface-dark !important; + color: $tutor-text-primary-inverse !important; + } + + .vc-date[data-vc-date-selected='middle'][data-vc-date-selected] .vc-date__btn { + background-color: $tutor-surface-l1-hover !important; + color: $tutor-text-primary !important; + } +} + +.tutor-range-calendar-popover { + width: 700px; + max-width: 700px; + + @include tutor-breakpoint-down(md) { + width: 300px; + } + + [data-vc='calendar'] { + display: grid; + grid-template-columns: 150px 1fr; + grid-template-areas: + 'presets controls' + 'presets grid' + 'presets time' + 'presets footer'; + column-gap: $tutor-spacing-6; + padding: $tutor-spacing-5; + + @include tutor-breakpoint-down(md) { + grid-template-columns: 1fr; + grid-template-areas: + 'presets' + 'controls' + 'grid' + 'time' + 'footer'; + } + } + + button[data-vc-arrow] { + padding: $tutor-spacing-none; + } + + .vc-presets { + grid-area: presets; + @include tutor-flex(column); + + button { + @include tutor-button-reset(); + @include tutor-typography(tiny, medium); + @include tutor-flex(row, center, space-between); + border-radius: $tutor-radius-sm; + padding: $tutor-spacing-3 $tutor-spacing-5; + text-align: left; + + .vc-preset-icon { + opacity: 0; + transition: opacity 0.2s ease; + } + + &:hover { + background-color: $tutor-tab-sidebar-l2-hover; + } + + &[data-active] { + background-color: $tutor-tab-sidebar-l2-active; + color: $tutor-text-brand; + + .vc-preset-icon { + opacity: 1; + } + } + } + + @include tutor-breakpoint-down(md) { + flex-direction: row; + flex-wrap: wrap; + border-bottom: 1px solid $tutor-border-idle; + padding-bottom: $tutor-spacing-4; + margin-bottom: $tutor-spacing-4; + + button { + width: 50%; + } + } + } + + .vc-controls { + grid-area: controls; + padding: $tutor-spacing-2 $tutor-spacing-none $tutor-spacing-none $tutor-spacing-none; + } + + .vc-grid { + grid-area: grid; + display: flex; + gap: $tutor-spacing-6; + + @include tutor-breakpoint-down(md) { + .vc-column:nth-child(n+2) { + display: none; + } + } + } + + [data-vc='time'] { + grid-area: time; + } + + .vc-footer { + grid-area: footer; + } +} diff --git a/assets/core/scss/components/_card.scss b/assets/core/scss/components/_card.scss new file mode 100644 index 0000000000..9e8f6af6f0 --- /dev/null +++ b/assets/core/scss/components/_card.scss @@ -0,0 +1,37 @@ +// Card Component +// Base card styles using mixins + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-card { + @include tutor-card-base; + @include tutor-card-padding(medium); + @include tutor-card-radius(lg); +} + +// Padding modifiers +.tutor-card-padding-small { + @include tutor-card-padding(small); +} + +.tutor-card-padding-medium { + @include tutor-card-padding(medium); +} + +.tutor-card-padding-large { + @include tutor-card-padding(large); +} + +// Border radius modifiers +.tutor-card-rounded-md { + @include tutor-card-radius(md); +} + +.tutor-card-rounded-lg { + @include tutor-card-radius(lg); +} + +.tutor-card-rounded-2xl { + @include tutor-card-radius(2xl); +} \ No newline at end of file diff --git a/assets/core/scss/components/_file-uploader.scss b/assets/core/scss/components/_file-uploader.scss new file mode 100644 index 0000000000..764a8feb25 --- /dev/null +++ b/assets/core/scss/components/_file-uploader.scss @@ -0,0 +1,95 @@ +@use '../tokens/' as *; +@use '../mixins/' as *; + +.tutor-file-uploader { + @include tutor-flex(column, center, center); + min-height: 228px; + background-color: $tutor-surface-brand-tertiary; + background-image: $tutor-file-uploader-background-image; + border-radius: $tutor-input-radius; + padding: $tutor-spacing-6; + cursor: pointer; + position: relative; + @include tutor-transition(); + + &.tutor-file-uploader-drag-over { + background-color: $tutor-surface-brand-secondary; + } + + &.tutor-file-uploader-disabled { + cursor: not-allowed; + pointer-events: none; + } + + &-icon { + @include tutor-flex(row, center, center); + color: $tutor-icon-hover; + margin-bottom: $tutor-spacing-6; + } + + &-content { + @include tutor-flex(column, center, center); + gap: $tutor-spacing-2; + text-align: center; + margin-bottom: $tutor-spacing-8; + } + + &-title { + @include tutor-typography(small, medium, primary); + margin: 0; + } + + &-subtitle { + @include tutor-typography(tiny, regular, subdued); + margin: 0; + } + + &-input { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +.tutor-file-preview { + @include tutor-flex(row, center, center); + position: relative; + width: 100%; + min-height: 228px; + border-radius: $tutor-radius-md; + overflow: hidden; + background-color: $tutor-surface-brand-tertiary; + + img { + max-width: 100%; + height: auto; + object-fit: contain; + } + + &-overlay { + @include tutor-flex(row, center, center); + position: absolute; + inset: 0; + background-color: rgba($color: $tutor-surface-dark, $alpha: 0.8); + opacity: 0; + pointer-events: none; + backdrop-filter: blur(4px); + @include tutor-transition(opacity); + } + + &:hover { + .tutor-file-preview-overlay { + opacity: 1; + pointer-events: auto; + } + } + + &-actions { + @include tutor-flex(row, center, center); + gap: $tutor-spacing-3; + } +} diff --git a/assets/core/scss/components/_index.scss b/assets/core/scss/components/_index.scss new file mode 100644 index 0000000000..1cc2c9a217 --- /dev/null +++ b/assets/core/scss/components/_index.scss @@ -0,0 +1,30 @@ +@forward 'accordion'; +@forward 'alert'; +@forward 'button'; +@forward 'card'; +@forward 'input'; +@forward 'modal'; +@forward 'popover'; +@forward 'progress'; +@forward 'preview-trigger'; +@forward 'section-separator'; +@forward 'skeleton'; +@forward 'statics'; +@forward 'tabs'; +@forward 'nav'; +@forward 'toast'; +@forward 'tooltip'; +@forward 'file-uploader'; +@forward 'badge'; +@forward 'result-badge'; +@forward 'pagination'; +@forward 'file-uploader'; +@forward 'table'; +@forward 'avatar'; +@forward 'select'; +@forward 'attachment-card'; +@forward 'calendar'; +@forward 'loading-spinner'; +@forward 'time-input'; +@forward 'status-select'; +@forward 'star-rating'; diff --git a/assets/core/scss/components/_input.scss b/assets/core/scss/components/_input.scss new file mode 100644 index 0000000000..1fbfa0754d --- /dev/null +++ b/assets/core/scss/components/_input.scss @@ -0,0 +1,285 @@ +@use '../mixins' as *; +@use '../tokens' as *; + +.tutor-input-field { + position: relative; + width: 100%; + + .tutor-input-wrapper { + position: relative; + @include tutor-flex(row, center, flex-start); + gap: $tutor-spacing-3; + + .tutor-label { + margin: 0; + } + + .tutor-checkbox, + .tutor-radio, + .tutor-switch { + margin: 0; + } + } + + .tutor-input { + @include tutor-input-base; + + &.tutor-input-content { + &-left { + @include padding-start($tutor-spacing-10); + } + + &-right { + @include padding-end($tutor-spacing-10); + } + + &-clear { + @include padding-end(36px); + } + } + + &-sm { + @include tutor-input-base(sm); + } + + &-lg { + @include tutor-input-base(lg); + } + } + + .tutor-text-area { + @include tutor-textarea; + } + + .tutor-wp-editor-container { + + .wp-editor-container, + .mce-tinymce { + border-radius: $tutor-input-radius; + border-color: $tutor-border-idle; + } + + .mce-panel { + background-color: $tutor-surface-l1 !important; + border-color: $tutor-border-idle !important; + } + + .wp-editor-area { + background-color: $tutor-surface-l1; + color: $tutor-text-primary; + } + + .wp-editor-tabs { + .wp-switch-editor { + background-color: $tutor-surface-l2; + color: $tutor-text-secondary; + border-color: $tutor-border-idle; + + &:hover { + background-color: $tutor-surface-l1-hover; + } + + &.wp-switch-editor-active { + background-color: $tutor-surface-l1; + color: $tutor-text-primary; + } + } + } + + .mce-toolbar-grp, + .quicktags-toolbar { + border-top-left-radius: $tutor-input-radius; + border-top-right-radius: $tutor-input-radius; + } + + .mce-toolbar:last-of-type { + border-top: none; + } + + .mce-statusbar { + border-bottom-left-radius: $tutor-input-radius; + border-bottom-right-radius: $tutor-input-radius; + background-color: $tutor-surface-l1; + border-top-color: $tutor-border-idle; + } + + .mce-btn { + background-color: transparent !important; + + button { + color: $tutor-icon-secondary; + + .mce-ico { + color: $tutor-icon-secondary; + } + } + + &:hover, + &.mce-active { + background-color: $tutor-surface-l1-hover !important; + border-color: transparent !important; + + button { + color: $tutor-icon-hover; + + .mce-ico { + color: $tutor-icon-hover; + } + } + } + + &.mce-active { + background-color: $tutor-surface-l2-hover !important; + } + } + } + + .tutor-checkbox { + @include tutor-checkbox; + + &-md { + @include tutor-checkbox(md); + } + + &-intermediate { + @include tutor-checkbox(md, true); + } + } + + .tutor-radio { + @include tutor-radio; + + &-md { + @include tutor-radio(md); + } + } + + .tutor-switch { + @include tutor-switch; + + &-md { + @include tutor-switch(md); + } + } + + .tutor-label { + @include tutor-typography('small', 'medium'); + @include tutor-flex(row, center); + gap: $tutor-spacing-3; + margin-bottom: $tutor-spacing-3; + + &-required { + &::after { + content: '*'; + color: $tutor-text-critical; + margin-left: $tutor-spacing-1; + } + } + } + + .tutor-input-help-icon { + @include tutor-button-reset; + @include tutor-flex(row, center, center); + color: $tutor-icon-subdued; + cursor: help; + } + + .tutor-input-clear-button { + @include tutor-flex(row, center, center); + @include tutor-button-base; + @include tutor-button-variant('ghost'); + padding: $tutor-spacing-1; + border-radius: $tutor-radius-sm; + flex-shrink: 0; + position: absolute; + @include right($tutor-spacing-5); + top: 50%; + transform: translateY(-50%); + color: $tutor-icon-idle; + } + + .tutor-input-content { + position: absolute; + top: 50%; + transform: translateY(-50%); + @include tutor-flex(row, center, center); + color: $tutor-icon-secondary; + pointer-events: none; + + &.tutor-input-content-left { + @include left($tutor-spacing-5); + } + + &.tutor-input-content-right { + @include right($tutor-spacing-5); + } + + .tutor-input-password-toggle { + @include tutor-button-reset; + pointer-events: auto; + cursor: pointer; + color: inherit; + + &:hover { + color: $tutor-icon-hover; + } + } + } + + .tutor-input-wrapper:has(.tutor-input-sm) { + .tutor-input.tutor-input-content-left { + @include padding-start($tutor-spacing-9); + } + + .tutor-input.tutor-input-content-right { + @include padding-end($tutor-spacing-9); + } + + .tutor-input-content-left { + @include left($tutor-spacing-4); + } + + &.tutor-input-content-right { + @include right($tutor-spacing-4); + } + } + + .tutor-error-text { + @include tutor-typography('tiny', 'regular', 'critical'); + } + + .tutor-help-text { + @include tutor-typography('tiny', 'regular', 'subdued'); + } + + .tutor-error-text, + .tutor-help-text { + &:not(:empty) { + margin-top: $tutor-spacing-3; + } + } + + &-error { + .tutor-input { + border: 1px solid $tutor-border-error; + --tutor-input-border-shadow-current: none; + box-shadow: $tutor-shadow-xs; + + &:focus { + @include tutor-focus-ring('error'); + border-color: $tutor-border-error; + } + } + + .tutor-wp-editor-container { + outline: 1px solid $tutor-border-error; + border-radius: $tutor-input-radius; + box-shadow: $tutor-shadow-xs; + + &:focus { + @include tutor-focus-ring('error'); + border-color: $tutor-border-error; + } + } + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_loading-spinner.scss b/assets/core/scss/components/_loading-spinner.scss new file mode 100644 index 0000000000..cee12773bf --- /dev/null +++ b/assets/core/scss/components/_loading-spinner.scss @@ -0,0 +1,9 @@ +@use '../tokens/' as *; +@use '../mixins/' as *; + +.tutor-loading-spinner { + @include tutor-flex(); + @include tutor-loading-spinner(); + padding: $tutor-spacing-8; + position: relative; +} diff --git a/assets/core/scss/components/_modal.scss b/assets/core/scss/components/_modal.scss new file mode 100644 index 0000000000..542ce102ad --- /dev/null +++ b/assets/core/scss/components/_modal.scss @@ -0,0 +1,122 @@ +@use '../tokens' as *; +@use '../mixins' as *; +@use '../mixins/rtl' as rtl; +@use '../components/button' as *; + +.tutor-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $tutor-z-highest; + display: flex; + align-items: center; + justify-content: center; + + &-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #00000099; + backdrop-filter: blur(20px); + + // Transitions + &-enter { + @include tutor-transition(opacity, 150ms, ease-out); + } + + &-transition { + opacity: 0; + } + + &-transition-reset { + opacity: 1; + } + + &-leave { + @include tutor-transition(opacity, 150ms, ease-in); + } + } + + &-content { + position: relative; + width: 100%; + max-width: 500px; + max-height: calc(100dvh - 40px); + background-color: $tutor-surface-l1; + border-radius: $tutor-modal-radius; + box-shadow: $tutor-modal-shadow; + overflow-y: auto; + + // Transitions + &-enter { + @include tutor-transition((opacity, transform), 150ms, ease-out); + } + + &-transition { + opacity: 0; + transform: translateY(16px) scale(0.95); + } + + &-transition-reset { + opacity: 1; + transform: translateY(0) scale(1); + } + + &-leave { + @include tutor-transition((opacity, transform), 150ms, ease-in); + } + } + + &-header { + @include tutor-flex(column); + gap: $tutor-spacing-2; + padding: $tutor-spacing-7 $tutor-spacing-7 $tutor-spacing-none $tutor-spacing-7; + } + + &-title { + @include tutor-typography('h5', 'semibold', 'secondary', 'heading'); + margin: 0; + } + + &-subtitle { + @include tutor-typography('p3', 'regular', 'secondary', 'body'); + } + + &-body { + padding: $tutor-spacing-7; + } + + &-close { + @include tutor-button-base; + @include tutor-button-icon-size(x-small); + @include tutor-button-variant(ghost); + position: absolute; + top: $tutor-spacing-4; + right: $tutor-spacing-4; + + svg { + color: $tutor-icon-idle !important; + } + } + + &-footer { + @include tutor-flex(row, center, flex-end); + gap: $tutor-spacing-4; + padding: $tutor-spacing-5 $tutor-spacing-7; + border-top: 1px solid $tutor-border-idle; + } +} + +@include tutor-breakpoint-down(md) { + .tutor-modal { + padding: $tutor-spacing-6; + + &-content { + max-height: 95vh; + } + } +} diff --git a/assets/core/scss/components/_nav.scss b/assets/core/scss/components/_nav.scss new file mode 100644 index 0000000000..cc90542aaa --- /dev/null +++ b/assets/core/scss/components/_nav.scss @@ -0,0 +1,156 @@ +// Dashboard Page Nav Styles + +@use '@Core/scss/tokens' as *; +@use '@Core/scss/mixins' as *; + +.tutor-nav { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + overflow-x: auto; + + button.tutor-nav-item, + .tutor-nav-item { + @include tutor-button-reset(); + @include tutor-typography(small, medium, secondary); + @include tutor-flex(row, center); + border-radius: $tutor-tab-radius; + font-weight: $tutor-tab-font-weight; + box-shadow: none; + gap: $tutor-spacing-3; + padding: 10px $tutor-spacing-6; + height: $tutor-tab-height; + white-space: nowrap; + @include tutor-transition((background-color, color, box-shadow)); + + &:hover { + color: $tutor-text-brand; + background-color: $tutor-tab-l3-active; + box-shadow: $tutor-tab-active-shadow; + } + + &:focus { + color: $tutor-text-brand; + background-color: $tutor-tab-l3-active; + box-shadow: none; + outline: none; + } + + &:focus-visible { + @include tutor-focus-ring('brand', true); + outline-offset: -2px; + } + + &.active { + background-color: $tutor-tab-l3-active; + color: $tutor-text-brand; + box-shadow: $tutor-tab-active-shadow; + } + + svg { + flex-shrink: 0; + } + } + + &-dropdown { + @include tutor-flex(column); + gap: $tutor-spacing-2; + min-width: 170px; + padding: $tutor-spacing-2 0px; + + &-item { + @include tutor-button-reset(); + @include tutor-typography(small); + @include tutor-flex(row, center); + gap: $tutor-spacing-5; + padding: $tutor-spacing-4 $tutor-spacing-5; + @include tutor-transition((background-color, color)); + + &:hover { + color: $tutor-text-primary; + background-color: $tutor-tab-l3-active-hover; + } + + &:focus { + color: $tutor-text-primary; + box-shadow: none; + outline: none; + } + + &.active { + background-color: $tutor-tab-l3-active; + color: $tutor-text-brand; + + &:hover { + background-color: $tutor-tab-l3-active; + color: $tutor-text-brand; + + svg { + color: $tutor-icon-brand; + } + } + } + } + } + + // Variant styles + &.tutor-nav-secondary { + .tutor-nav-item { + border-bottom: 2px solid transparent; + @include tutor-transition((border-color, background-color)); + + &.active { + background-color: transparent; + border-bottom-color: $tutor-border-brand; + border-radius: $tutor-radius-none; + } + } + } + + // Size variants + &.tutor-nav-small { + .tutor-nav-item { + padding: 7px $tutor-spacing-5; + border-radius: $tutor-tab-radius-sm; + height: $tutor-tab-height-sm; + } + + .tutor-nav-dropdown { + min-width: 150px; + } + } + + &.tutor-nav-large { + .tutor-nav-item { + gap: $tutor-spacing-4; + padding: 11px $tutor-spacing-6; + height: $tutor-tab-height-lg; + } + + .tutor-nav-dropdown { + min-width: 200px; + + .tutor-nav-dropdown-item { + padding: $tutor-spacing-5 $tutor-spacing-6; + } + } + } + + @include tutor-breakpoint-down(sm) { + gap: 0; + + .tutor-nav-item { + flex: 1; + min-width: fit-content; + justify-content: center; + font-size: $tutor-font-size-tiny; + line-height: $tutor-line-height-medium; + padding: $tutor-spacing-4 $tutor-spacing-5; + border-radius: $tutor-tab-radius; + + svg { + width: 16px; + height: 16px; + } + } + } +} diff --git a/assets/core/scss/components/_pagination.scss b/assets/core/scss/components/_pagination.scss new file mode 100644 index 0000000000..840e7aa12f --- /dev/null +++ b/assets/core/scss/components/_pagination.scss @@ -0,0 +1,33 @@ +// Pagination scss + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-pagination { + @include tutor-pagination-base; +} + +.tutor-pagination-info { + @include tutor-pagination-info; +} + +.tutor-pagination-list { + @include tutor-pagination-list; +} + +.tutor-pagination-item { + @include tutor-pagination-item; + + &-active { + @include tutor-pagination-item-variant(active); + } + + &-prev, + &-next { + @include tutor-pagination-item-variant(prev-next); + } +} + +.tutor-pagination-ellipsis { + @include tutor-pagination-ellipsis; +} \ No newline at end of file diff --git a/assets/core/scss/components/_popover.scss b/assets/core/scss/components/_popover.scss new file mode 100644 index 0000000000..ea11909105 --- /dev/null +++ b/assets/core/scss/components/_popover.scss @@ -0,0 +1,138 @@ +// Popover Component +// RTL-aware popover positioning and styling with collision detection + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-popover { + position: fixed; + z-index: $tutor-z-dropdown; + max-width: 276px; + background-color: $tutor-surface-l1; + border: $tutor-popover-border; + border-radius: $tutor-popover-radius; + box-shadow: $tutor-popover-shadow; + overflow: hidden; + + &-header { + padding: $tutor-spacing-4 $tutor-spacing-4 0; + + .tutor-popover-title { + @include tutor-typography(p1, semibold, primary); + margin: 0; + } + + .tutor-popover-close { + position: absolute; + top: $tutor-spacing-2; + right: $tutor-spacing-2; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + border-radius: $tutor-radius-5xl; + color: $tutor-icon-idle; + cursor: pointer; + @include tutor-transition(); + + @include tutor-flex-center(); + + [dir='rtl'] & { + right: auto; + left: $tutor-spacing-2; + } + + &:hover, + &:focus { + background-color: $tutor-surface-l2; + color: $tutor-icon-hover; + outline: none; + } + } + } + + &-body { + @include tutor-typography(p2, regular, primary); + padding: $tutor-spacing-4; + } + + &-footer { + padding: $tutor-spacing-4; + border-top: 1px solid $tutor-border-idle; + margin-top: $tutor-spacing-4; + + @include tutor-flex(row, center, flex-end); + gap: $tutor-spacing-2; + + [dir='rtl'] & { + justify-content: flex-start; + flex-direction: row-reverse; + } + } + + .tutor-popover-menu { + min-width: 170px; + padding: $tutor-spacing-3 0; + + .tutor-popover-menu-item { + width: 100%; + @include tutor-button-reset(); + @include tutor-flex(row, center, flex-start); + @include tutor-typography(small); + @include tutor-text-truncate(); + gap: $tutor-spacing-5; + padding: $tutor-spacing-4 $tutor-spacing-5; + @include tutor-transition((color, background-color)); + + svg { + flex-shrink: 0; + } + + &:hover, + &:focus { + color: $tutor-text-primary; + background-color: $tutor-tab-sidebar-l2-hover; + } + + &.tutor-active { + background-color: $tutor-tab-sidebar-l2-active; + color: $tutor-text-brand; + opacity: 1; + } + } + } + + // Size variants + &-sm { + max-width: 200px; + font-size: $tutor-font-size-p3; + } + + &-lg { + max-width: 400px; + } + + &-xl { + max-width: 500px; + } + + // Hidden by default + &[x-cloak] { + display: none !important; + } +} + +// Responsive adjustments +@media (max-width: 576px) { + .tutor-popover { + max-width: calc(100vw - 56px); + + &-header, + &-body, + &-footer { + padding-left: $tutor-spacing-3; + padding-right: $tutor-spacing-3; + } + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_preview-trigger.scss b/assets/core/scss/components/_preview-trigger.scss new file mode 100644 index 0000000000..5c3e0b43ab --- /dev/null +++ b/assets/core/scss/components/_preview-trigger.scss @@ -0,0 +1,130 @@ +// Preview Trigger Component +// Styles for course/lesson preview on hover + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-preview-trigger { + display: inline-flex; + vertical-align: middle; + max-width: 100%; + min-width: 0; + width: 100%; + position: relative; + + &-text { + font-style: italic; + text-decoration: underline dashed $tutor-border-hover; + text-underline-offset: 4px; + color: $tutor-text-secondary; + cursor: pointer; + @include tutor-transition(color); + padding-inline-end: $tutor-spacing-1; + + &:focus { + outline: none; + } + + &:focus-visible { + @include tutor-focus-ring('brand', true); + outline-offset: 2px; + border-radius: $tutor-radius-sm; + } + + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + vertical-align: bottom; + } +} + +.tutor-preview-card { + background: $tutor-surface-l1; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-2xl; + box-shadow: $tutor-shadow-lg; + padding: $tutor-spacing-4 $tutor-spacing-4 $tutor-spacing-5 $tutor-spacing-4; + width: 194px; + max-width: calc(100vw - 16px); + overflow: hidden; + animation: tutor-preview-fade-in 0.2s ease; +} + +.tutor-preview-card-content { + display: flex; + flex-direction: column; + gap: $tutor-spacing-4; +} + +.tutor-preview-card-thumbnail { + width: 100%; + height: 90px; + border-radius: $tutor-radius-md; + object-fit: cover; + display: block; +} + +.tutor-preview-card-body { + display: flex; + flex-direction: column; + gap: $tutor-spacing-1; +} + +.tutor-preview-card-title { + @include tutor-typography(p2, medium, primary, heading); + margin: 0; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + a { + text-decoration: none; + color: inherit; + @include tutor-transition(color); + + &:hover { + color: $tutor-text-brand; + } + + &:focus-visible { + @include tutor-focus-ring('brand', true); + outline-offset: 2px; + border-radius: $tutor-radius-sm; + } + } +} + +.tutor-preview-card-instructor { + @include tutor-typography(tiny, regular, subdued); + + a { + color: $tutor-text-secondary; + text-decoration: none; + @include tutor-transition(color); + + &:hover { + color: $tutor-text-brand; + } + + &:focus-visible { + @include tutor-focus-ring('brand', true); + outline-offset: 2px; + border-radius: $tutor-radius-sm; + } + } +} + +@keyframes tutor-preview-fade-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/assets/core/scss/components/_progress.scss b/assets/core/scss/components/_progress.scss new file mode 100644 index 0000000000..01992def57 --- /dev/null +++ b/assets/core/scss/components/_progress.scss @@ -0,0 +1,94 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-progress-bar { + width: 100%; + height: $tutor-progress-height; + background-color: $tutor-actions-gray-secondary; + box-shadow: inset 0 0 1.75px rgba(0, 0, 0, 0.16); + border-radius: $tutor-radius-full; + overflow: hidden; + position: relative; + + &-fill { + --tutor-progress-width: 0%; + --tutor-progress-start: 0%; + height: 100%; + width: 100%; + transform: translateX(calc(var(--tutor-progress-width) - 100%)); + background-color: $tutor-actions-success-primary; + border-radius: inherit; + position: relative; + @include tutor-transition(transform, 0.3s); + + &::before { + content: ''; + position: absolute; + top: $tutor-progress-indicator-top; + right: $tutor-spacing-2; + height: $tutor-progress-indicator-height; + width: 8.16px; + background-color: $tutor-actions-success-secondary; + border-radius: inherit; + opacity: 0.4; + } + + &::after { + content: ''; + display: $tutor-progress-indicator-secondary-display; + position: absolute; + top: $tutor-progress-indicator-top; + left: $tutor-spacing-2; + right: calc(#{$tutor-spacing-2} + 8.16px + 1px); + height: $tutor-progress-indicator-height; + background-color: $tutor-actions-success-secondary; + border-radius: inherit; + opacity: 0.4; + } + } + + &.tutor-progress-bar-brand { + background-color: $tutor-actions-gray-secondary; + + .tutor-progress-bar-fill { + background-color: $tutor-actions-brand-primary; + + &::before { + background-color: $tutor-actions-brand-secondary; + } + + &::after { + background-color: $tutor-actions-brand-secondary; + } + } + } + + &.tutor-progress-bar-warning { + background-color: $tutor-actions-warning-tertiary; + + .tutor-progress-bar-fill { + background-color: $tutor-actions-warning-primary; + + &::before { + background-color: $tutor-actions-warning-tertiary; + } + + &::after { + background-color: $tutor-actions-warning-tertiary; + } + } + } + + &[data-tutor-animated] .tutor-progress-bar-fill { + animation: tutor-progress-fill-animate 1s ease-out forwards; + } +} + +@keyframes tutor-progress-fill-animate { + from { + transform: translateX(calc(var(--tutor-progress-start, 0%) - 100%)); + } + to { + transform: translateX(calc(var(--tutor-progress-width) - 100%)); + } +} diff --git a/assets/core/scss/components/_result-badge.scss b/assets/core/scss/components/_result-badge.scss new file mode 100644 index 0000000000..ab3ee63bb6 --- /dev/null +++ b/assets/core/scss/components/_result-badge.scss @@ -0,0 +1,41 @@ +// Result badge style for Quiz + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-result-badge { + @include tutor-typography(medium, medium); + width: fit-content; + border: 1px solid; + border-radius: $tutor-radius-full; + padding-inline-start: $tutor-spacing-8; + padding-inline-end: $tutor-spacing-4; + margin-inline-start: $tutor-spacing-4; + position: relative; + + @include tutor-breakpoint-down(sm) { + @include tutor-text(small); + } + + svg { + position: absolute; + left: -$tutor-spacing-4; + top: 50%; + transform: translateY(-50%); + } + + &.passed { + color: $tutor-text-success; + border-color: $tutor-border-success-secondary; + } + + &.pending { + color: $tutor-text-warning; + border-color: $tutor-border-warning-secondary; + } + + &.failed { + color: $tutor-text-critical; + border-color: $tutor-border-error-secondary; + } +} diff --git a/assets/core/scss/components/_section-separator.scss b/assets/core/scss/components/_section-separator.scss new file mode 100644 index 0000000000..6a4670c0db --- /dev/null +++ b/assets/core/scss/components/_section-separator.scss @@ -0,0 +1,25 @@ +// Section Separator Component +// Simple horizontal line for visual separation between sections + +@use '../tokens' as *; + +.tutor-section-separator { + width: 100%; + height: 1px; + background-color: $tutor-border-idle; + border: none; + margin: 0; + padding: 0; + flex-shrink: 0; +} + +.tutor-section-separator-vertical { + width: 1px; + height: 1.25rem; + background-color: $tutor-border-idle; + border: none; + margin: auto 0; + padding: 0; + flex-shrink: 0; + align-self: stretch; +} \ No newline at end of file diff --git a/assets/core/scss/components/_select.scss b/assets/core/scss/components/_select.scss new file mode 100644 index 0000000000..fe077a0095 --- /dev/null +++ b/assets/core/scss/components/_select.scss @@ -0,0 +1,364 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-select { + position: relative; + width: 100%; + @include tutor-typography(small); + + // Trigger button + .tutor-select-trigger { + @include tutor-flex(row, center, space-between); + width: 100%; + min-height: 40px; + gap: $tutor-spacing-3; + padding: $tutor-spacing-4 $tutor-spacing-5; + background-color: $tutor-surface-base; + border: 1px solid $tutor-border-idle; + border-color: $tutor-input-border-color; + border-radius: $tutor-input-radius; + --tutor-input-border-shadow-current: #{$tutor-input-border-shadow}; + box-shadow: var(--tutor-input-border-shadow-current); + cursor: pointer; + text-align: left; + @include tutor-transition(); + + &:hover, + &:focus { + @include tutor-focus-ring(); + border-color: $tutor-border-brand-tertiary; + background-color: $tutor-surface-l1; + --tutor-input-border-shadow-current: none; + } + + &:has(.tutor-select-value-text):not(:has(.tutor-select-value-placeholder)) { + background-color: $tutor-surface-l1; + } + + &[disabled] { + background-color: $tutor-surface-l1-hover; + cursor: not-allowed; + --tutor-input-border-shadow-current: none; + } + + .tutor-input-field-error & { + border: 1px solid $tutor-border-error; + box-shadow: $tutor-shadow-xs; + + &:focus { + @include tutor-focus-ring('error'); + border-color: $tutor-border-error; + } + } + } + + // Trigger content + &-value { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + flex: 1; + min-width: 0; + overflow: hidden; + + &-text { + @include tutor-typography(small); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-placeholder { + color: $tutor-text-subdued; + } + + &-icon { + flex-shrink: 0; + @include tutor-flex(row, center, center); + color: $tutor-icon-secondary; + width: 16px; + height: 16px; + } + } + + // Multiple selected items + &-tags { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + flex-wrap: wrap; + flex: 1; + min-width: 0; + } + + &-tag { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + padding: $tutor-spacing-1 $tutor-spacing-2; + background: $tutor-surface-l2; + border-radius: $tutor-radius-sm; + font-size: 12px; + line-height: 1.4; + max-width: 150px; + + &-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-remove { + @include tutor-button-reset; + @include tutor-flex(row, center, center); + padding: 0; + color: $tutor-icon-secondary; + cursor: pointer; + @include tutor-transition(color); + + &:hover { + color: $tutor-icon-critical; + } + } + } + + // Actions (clear, arrow) + &-actions { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + flex-shrink: 0; + } + + .tutor-select-clear { + @include tutor-button-base; + @include tutor-button-variant('ghost'); + @include tutor-flex(row, center, center); + @include margin-end($tutor-spacing-2); + padding: $tutor-spacing-1; + border-radius: $tutor-radius-sm; + color: $tutor-icon-secondary; + cursor: pointer; + @include tutor-transition(color); + } + + &-arrow { + @include tutor-flex(row, center, center); + color: $tutor-icon-secondary; + @include tutor-transition(transform); + + &[data-open='true'] { + transform: rotate(180deg); + } + + // RTL: Mirror the arrow direction + [dir='rtl'] & { + transform: scaleX(-1); + + &[data-open='true'] { + transform: scaleX(-1) rotate(180deg); + } + } + } + + // Dropdown menu + &-menu { + position: absolute; + @include left(0); + @include right(0); + z-index: $tutor-z-dropdown; + background: $tutor-surface-l1; + border: $tutor-popover-border; + border-radius: $tutor-popover-radius; + box-shadow: $tutor-popover-shadow; + overflow: hidden; + display: flex; + flex-direction: column; + + &[data-position='bottom'] { + top: calc(100% + 4px); + } + + &[data-position='top'] { + bottom: calc(100% + 4px); + } + } + + // Search input + &-search { + position: relative; + padding: $tutor-spacing-4 $tutor-spacing-5; + border-bottom: 1px solid $tutor-border-idle; + flex-shrink: 0; + + &-icon { + position: absolute; + top: 50%; + @include left($tutor-spacing-7); + transform: translateY(-50%); + color: $tutor-icon-secondary; + pointer-events: none; + @include tutor-flex(row, center, center); + } + + .tutor-select-search-input { + @include tutor-input-base(); + padding-block: $tutor-spacing-3; + padding-inline-start: $tutor-spacing-9; + } + } + + // Options list + &-options { + overflow-y: auto; + max-height: 280px; + padding: $tutor-spacing-3 0; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: $tutor-border-idle; + border-radius: $tutor-radius-full; + + &:hover { + background: $tutor-icon-secondary; + } + } + } + + // Option group + &-group { + &-label { + padding: $tutor-spacing-3 $tutor-spacing-5; + @include tutor-typography(tiny, semibold, subdued); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &-options { + padding-bottom: $tutor-spacing-2; + } + } + + // Single option + &-option { + @include tutor-flex(row, center); + gap: $tutor-spacing-3; + padding: $tutor-spacing-4 $tutor-spacing-5; + cursor: pointer; + position: relative; + @include tutor-transition(background-color); + + &-icon { + flex-shrink: 0; + @include tutor-flex(row, center, center); + color: $tutor-icon-secondary; + width: 16px; + height: 16px; + } + + &-content { + flex: 1; + min-width: 0; + } + + &-label { + @include tutor-typography(small); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-description { + @include tutor-typography(tiny, regular, subdued); + margin-top: $tutor-spacing-1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // States + &:hover:not([data-disabled='true']) { + background: $tutor-surface-l2; + } + + &[data-highlighted='true']:not([data-disabled='true']) { + background: $tutor-surface-l2; + } + + &[data-selected='true'] { + background: $tutor-surface-l2; + } + + &[data-disabled='true'] { + color: $tutor-text-subdued; + cursor: not-allowed; + opacity: 0.5; + + &:hover { + background: transparent; + } + } + } + + // Empty state + &-empty { + @include tutor-typography(small, regular, subdued); + padding: $tutor-spacing-6 $tutor-spacing-5; + text-align: center; + } + + // Loading state + &-loading { + @include tutor-typography(small, regular, subdued); + @include tutor-flex(row, center, center); + gap: $tutor-spacing-3; + padding: $tutor-spacing-6 $tutor-spacing-5; + + &-spinner { + width: 16px; + height: 16px; + border: 2px solid $tutor-border-idle; + border-top-color: $tutor-border-brand-secondary; + border-radius: $tutor-radius-full; + animation: tutor-select-spin 0.6s linear infinite; + } + } +} + +// Animations +@keyframes tutor-select-spin { + to { + transform: rotate(360deg); + } +} + +// Size variants +.tutor-select-sm { + .tutor-select-trigger { + min-height: 32px; + padding: $tutor-spacing-2 $tutor-spacing-3; + @include tutor-typography(tiny); + } + + .tutor-select-option { + padding: $tutor-spacing-2 $tutor-spacing-4; + @include tutor-typography(tiny); + } +} + +.tutor-select-lg { + .tutor-select-trigger { + min-height: 48px; + padding: $tutor-spacing-5 $tutor-spacing-6; + @include tutor-typography(medium); + } + + .tutor-select-option { + padding: $tutor-spacing-4 $tutor-spacing-6; + @include tutor-typography(medium); + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_skeleton.scss b/assets/core/scss/components/_skeleton.scss new file mode 100644 index 0000000000..51d27fbcff --- /dev/null +++ b/assets/core/scss/components/_skeleton.scss @@ -0,0 +1,37 @@ +@use '../tokens' as *; + +.tutor-skeleton { + display: block; + width: 100%; + height: $tutor-spacing-6; + border-radius: $tutor-radius-md; + background-color: rgba(0, 0, 0, 0.11); + position: relative; + mask-image: radial-gradient(white, black); + -webkit-mask-image: -webkit-radial-gradient(center, white, black); + overflow: hidden; + flex-shrink: 0; + + &::after { + content: ''; + background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.05), transparent); + position: absolute; + transform: translateX(-100%); + inset: 0; + animation: var(--tutor-animation-duration, 1.6s) linear 0.5s infinite normal none running wave; + } + + &-round { + border-radius: $tutor-radius-full; + } +} + +@keyframes wave { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_star-rating.scss b/assets/core/scss/components/_star-rating.scss new file mode 100644 index 0000000000..a2e7615e78 --- /dev/null +++ b/assets/core/scss/components/_star-rating.scss @@ -0,0 +1,96 @@ +@use '@Core/scss/tokens' as *; +@use '@Core/scss/mixins' as *; + +.tutor-star-rating-container { + @include tutor-flex(row, center, space-between); + + button.tutor-star-rating-icon-btn { + @include tutor-button-reset(); + min-height: unset; + } + + &.is-emoji-view { + @include tutor-flex-column; + gap: $tutor-spacing-7; + + .tutor-star-rating-emoji-btn { + @include tutor-button-reset; + width: 74px; + height: 74px; + background-color: $tutor-surface-base; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-2xl; + @include tutor-flex-column; + align-items: center; + justify-content: center; + cursor: pointer; + @include tutor-transition(background-color); + + .tutor-rating-emoji-img { + width: 32px; + height: 32px; + object-fit: contain; + } + + .tutor-rating-label { + @include tutor-typography(p3, medium, subdued); + } + + &:hover { + background-color: $tutor-surface-l1-hover; + } + + &.is-active { + &.is-poor { + background-color: $tutor-surface-critical; + border-color: $tutor-border-error-secondary; + + .tutor-rating-label { + color: $tutor-text-critical; + } + } + + &.is-fair { + background-color: $tutor-surface-warning-hover; + border-color: $tutor-border-warning-tertiary; + + .tutor-rating-label { + color: $tutor-text-warning; + } + } + + &.is-okay { + background-color: $tutor-surface-exception6; + border-color: $tutor-border-exception6; + + .tutor-rating-label { + color: $tutor-text-exception5; + } + } + + &.is-good { + background-color: $tutor-surface-brand-quaternary; + border-color: $tutor-border-brand; + + .tutor-rating-label { + color: $tutor-text-brand; + } + } + + &.is-amazing { + background-color: $tutor-surface-success; + border-color: $tutor-border-success-secondary; + + .tutor-rating-label { + color: $tutor-text-success; + } + } + } + + @include tutor-breakpoint-down(md) { + width: 54px; + height: 54px; + } + } + } +} diff --git a/assets/core/scss/components/_statics.scss b/assets/core/scss/components/_statics.scss new file mode 100644 index 0000000000..ec97eb0039 --- /dev/null +++ b/assets/core/scss/components/_statics.scss @@ -0,0 +1,44 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-statics { + @include tutor-flex(row, center, center); + position: relative; + width: fit-content; + + &-progress { + transform: rotate(-90deg); + + &-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + @include tutor-typography('tiny', 'bold', 'primary', 'body'); + + &-large { + @include tutor-typography('h1', 'bold', 'primary', 'heading'); + } + } + } + + &-complete { + @include tutor-flex(row, center, center); + border-radius: $tutor-radius-full; + background: $tutor-actions-brand-primary; + + svg { + color: $tutor-icon-idle-inverse; + } + } + + &-locked { + @include tutor-flex(row, center, center); + border-radius: $tutor-radius-full; + background: transparent; + + svg { + color: $tutor-icon-idle; + } + } +} \ No newline at end of file diff --git a/assets/core/scss/components/_status-select.scss b/assets/core/scss/components/_status-select.scss new file mode 100644 index 0000000000..3b9026ae8d --- /dev/null +++ b/assets/core/scss/components/_status-select.scss @@ -0,0 +1,85 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-status-select { + @include tutor-typography(tiny, medium); + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: $tutor-spacing-2; + width: fit-content; + max-width: max-content; + border-radius: $tutor-radius-full; + padding: $tutor-spacing-none $tutor-spacing-4; + + select { + appearance: none; + background: transparent; + border: none; + outline: none; + width: 100%; + height: 32px; + padding: $tutor-spacing-none; + cursor: pointer; + z-index: $tutor-z-positive; + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + + &:focus { + box-shadow: none; + } + + &:disabled { + cursor: not-allowed; + } + } + + // Icons + &-icon, + &-arrow { + @include tutor-flex(row, center, center); + pointer-events: none; + z-index: $tutor-z-positive; + } + + &-icon { + &-wrapper, + span { + @include tutor-flex(row, center, center); + } + } + + // Variants based on badge logic + &-default { + background-color: $tutor-surface-l2; + color: $tutor-text-primary; + } + + &-primary { + background-color: $tutor-actions-brand-secondary; + color: $tutor-text-brand; + } + + &-success { + background-color: $tutor-actions-success-tertiary; + color: $tutor-text-success; + } + + &-warning { + background-color: $tutor-actions-warning-secondary; + color: $tutor-text-warning; + } + + &-danger, + &-error { + background-color: $tutor-actions-critical-secondary; + color: $tutor-text-critical; + } + + &-disabled { + background-color: $tutor-actions-gray-secondary; + color: $tutor-text-subdued; + } +} diff --git a/assets/core/scss/components/_table.scss b/assets/core/scss/components/_table.scss new file mode 100644 index 0000000000..6b1035267d --- /dev/null +++ b/assets/core/scss/components/_table.scss @@ -0,0 +1,85 @@ +// Table scss + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-table-wrapper { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + + &.tutor-table-bordered { + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-lg; + } + + &.tutor-table-column-borders { + table, + .tutor-table { + thead th, + tbody td { + @include border-end(1px solid $tutor-border-idle); + &:last-of-type, + &:last-child { + @include border-end(none); + } + } + } + } + + table { + min-width: 600px; + width: 100%; + } +} + +table.tutor-table, +.tutor-table { + @include tutor-typography('small', 'regular', 'secondary'); + width: 100%; + margin: 0px; + border: none; + border-collapse: collapse; + border-spacing: 0; + background-color: $tutor-surface-l1; + + thead { + background-color: $tutor-surface-l1-hover; + + th { + @include tutor-typography(tiny, regular, secondary); + text-align: left; + padding: $tutor-spacing-5; + border: none; + white-space: nowrap; + border-bottom: 1px solid $tutor-border-idle; + } + } + + tbody { + tr { + border-bottom: 1px solid $tutor-border-idle; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: $tutor-surface-l1-hover; + } + + &:nth-child(odd) { + > td { + background-color: transparent; + } + } + } + + td { + background-color: transparent; + padding: $tutor-spacing-5; + vertical-align: middle; + border: none; + } + } +} diff --git a/assets/core/scss/components/_tabs.scss b/assets/core/scss/components/_tabs.scss new file mode 100644 index 0000000000..31a8b00332 --- /dev/null +++ b/assets/core/scss/components/_tabs.scss @@ -0,0 +1,144 @@ +@use '../mixins' as *; +@use '../tokens' as *; +@use '../mixins/rtl' as rtl; + +button.tutor-tabs-tab, +.tutor-tabs-tab { + @include tutor-flex(row, center, flex-start); + @include tutor-typography('small'); + border-radius: $tutor-tab-radius; + font-weight: $tutor-tab-font-weight; + background: transparent; + border: none; + padding: $tutor-spacing-4 $tutor-spacing-6; + gap: $tutor-spacing-4; + text-transform: capitalize; + white-space: nowrap; + cursor: pointer; + color: $tutor-text-primary; + height: $tutor-tab-height; + box-shadow: none; + @include tutor-transition((background-color, color, box-shadow)); + margin: $tutor-spacing-none; + + @include tutor-breakpoint-down(sm) { + @include tutor-text('tiny'); + } + + &-active { + color: $tutor-text-brand; + background-color: $tutor-tab-l3-active; + box-shadow: $tutor-tab-active-shadow; + + svg { + color: $tutor-text-brand; + } + + &:focus, + &:hover { + box-shadow: none; + color: $tutor-text-brand; + background-color: $tutor-tab-l3-active; + box-shadow: $tutor-tab-active-shadow; + outline: none; + } + } + + &-md { + height: 34px; + @include tutor-text('tiny'); + padding: 7px $tutor-spacing-5; + gap: $tutor-spacing-2; + } + + &-sm { + @include tutor-text('tiny'); + padding: 6px $tutor-spacing-5; + gap: $tutor-spacing-2; + height: $tutor-tab-height-sm; + } + + &:hover:not(:disabled):not(.tutor-tabs-tab-active), + &:focus:not(:disabled):not(.tutor-tabs-tab-active) { + color: $tutor-text-primary; + background-color: $tutor-tab-l3-active-hover; + outline: none; + + svg { + color: $tutor-text-primary; + } + } + + &:disabled { + color: $tutor-text-subdued; + cursor: not-allowed; + + &:hover { + background-color: transparent; + } + } + + &:focus-visible { + @include tutor-focus-ring(); + } +} + +.tutor-tabs { + &-nav { + @include tutor-flex(row, center, flex-start); + gap: $tutor-spacing-2; + overflow-x: auto; + overflow-y: hidden; + position: relative; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + [dir='rtl'] & { + flex-direction: row-reverse; + } + } + + &-content { + min-height: 100px; + } + + // Vertical variant + &-vertical { + @include tutor-flex(row, flex-start, flex-start); + + [dir='rtl'] & { + flex-direction: row-reverse; + } + + .tutor-tabs-nav { + @include tutor-flex-column; + align-items: stretch; + min-width: 200px; + } + + .tutor-tabs-content { + flex: 1; + min-width: 0; + } + } +} + +@include tutor-breakpoint-down(md) { + .tutor-tabs { + &-vertical { + display: unset; + flex-direction: column; + overflow-x: auto; + overflow-y: hidden; + + .tutor-tabs-nav { + flex-direction: row; + overflow-x: auto; + } + } + } +} diff --git a/assets/core/scss/components/_time-input.scss b/assets/core/scss/components/_time-input.scss new file mode 100644 index 0000000000..81c039468a --- /dev/null +++ b/assets/core/scss/components/_time-input.scss @@ -0,0 +1,50 @@ +@use '../tokens' as *; +@use '../mixins' as *; +@use '../mixins/typography' as *; + +.tutor-time-input { + @include tutor-typography('small', 'regular', 'primary', 'body'); + + .tutor-input { + &:disabled { + cursor: not-allowed; + background: $tutor-surface-l1-hover; + } + } + + .tutor-input-content { + color: $tutor-icon-secondary; + } + + &-menu { + max-height: 380px; + overflow-y: auto; + padding: $tutor-spacing-3 0; + background: $tutor-surface-l1; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-lg; + box-shadow: $tutor-shadow-lg; + @include tutor-scrollbar; + } + + &-option { + @include tutor-button-reset; + @include tutor-typography('small', 'regular', 'subdued'); + text-align: start; + padding: $tutor-spacing-4; + border-radius: $tutor-radius-md; + margin: 0 $tutor-spacing-3; + width: calc(100% - (#{$tutor-spacing-3} * 2)); + + &:hover, + &[data-active='true'] { + background: $tutor-surface-l1-hover; + } + + &[data-selected='true'] { + background: $tutor-surface-brand-tertiary; + color: $tutor-text-brand; + outline: 1px solid $tutor-border-brand-tertiary; + } + } +} diff --git a/assets/core/scss/components/_toast.scss b/assets/core/scss/components/_toast.scss new file mode 100644 index 0000000000..e4953bc53f --- /dev/null +++ b/assets/core/scss/components/_toast.scss @@ -0,0 +1,435 @@ +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-toast-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.tutor-toast-container { + position: fixed !important; + z-index: $tutor-toast-z; + width: min(100vw - ($tutor-toast-offset-x * 2), $tutor-toast-max-width); + min-width: min($tutor-toast-min-width, calc(100vw - ($tutor-toast-offset-x * 2))); + pointer-events: none; + list-style: none; + margin: 0; + padding: 0; + outline: none; + + &[data-position-x='left'] { + left: $tutor-toast-offset-x; + } + + &[data-position-x='right'] { + right: $tutor-toast-offset-x; + } + + &[data-position-x='center'] { + left: 50%; + transform: translateX(-50%); + } + + &[data-position-y='top'] { + top: $tutor-toast-offset-y; + } + + &[data-position-y='bottom'] { + bottom: $tutor-toast-offset-y; + } +} + +[dir='rtl'] { + .tutor-toast-container[data-position-x='left'] { + left: auto; + right: $tutor-toast-offset-x; + } + + .tutor-toast-container[data-position-x='right'] { + right: auto; + left: $tutor-toast-offset-x; + } +} + +.tutor-toast-stack { + position: relative; + width: 100%; + height: var(--tutor-toast-front-height, 88px); + pointer-events: none; + transition: height 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.tutor-toast-item { + position: absolute; + inset-inline: 0; + bottom: 0; + pointer-events: none; + transition: + transform 400ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 400ms ease, + height 400ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translateY(var(--tutor-toast-y, 0px)) scale(var(--tutor-toast-scale, 1)); + opacity: var(--tutor-toast-opacity, 1); + + .tutor-toast-container[data-position-y='top'] & { + top: 0; + bottom: auto; + } + + &[data-front='true'] { + pointer-events: all; + z-index: 10; + } +} + +@keyframes tutor-toast-enter-from-bottom { + from { + transform: translateY(100%) scale(1); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +@keyframes tutor-toast-enter-from-top { + from { + transform: translateY(-100%) scale(1); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +.tutor-toast-item[data-entering][data-position-y='bottom'] { + animation: tutor-toast-enter-from-bottom $tutor-toast-animation-enter cubic-bezier(0.34, 1.4, 0.64, 1) forwards; +} + +.tutor-toast-item[data-entering][data-position-y='top'] { + animation: tutor-toast-enter-from-top $tutor-toast-animation-enter cubic-bezier(0.34, 1.4, 0.64, 1) forwards; +} + +@keyframes tutor-toast-exit-to-bottom { + from { + opacity: 1; + } + + to { + transform: translateY(calc(100% + $tutor-toast-offset-y)); + opacity: 0; + } +} + +@keyframes tutor-toast-exit-to-top { + from { + opacity: 1; + } + + to { + transform: translateY(calc(-100% - $tutor-toast-offset-y)); + opacity: 0; + } +} + +.tutor-toast-item[data-exiting][data-position-y='bottom'] { + animation: tutor-toast-exit-to-bottom $tutor-toast-animation-exit ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-exiting][data-position-y='top'] { + animation: tutor-toast-exit-to-top $tutor-toast-animation-exit ease forwards; + pointer-events: none; +} + +@keyframes tutor-toast-swipe-right { + to { + transform: translateX(calc(100% + 40px)); + opacity: 0; + } +} + +@keyframes tutor-toast-swipe-left { + to { + transform: translateX(calc(-100% - 40px)); + opacity: 0; + } +} + +.tutor-toast-item[data-swipe-out='right'] { + animation: tutor-toast-swipe-right 240ms ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-swipe-out='left'] { + animation: tutor-toast-swipe-left 240ms ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-swiping='true'] { + transition: none; +} + +.tutor-toast-item[data-front='false']:not([data-expanded]) > .tutor-toast-card > * { + opacity: 0; + transition: opacity 400ms; +} + +.tutor-toast-item[data-front='true'] > .tutor-toast-card > *, +.tutor-toast-item[data-expanded] > .tutor-toast-card > * { + opacity: 1; + transition: opacity 400ms; +} + +.tutor-toast-card { + position: relative; + display: flex; + align-items: center; + gap: $tutor-toast-gap; + width: 100%; + padding: $tutor-toast-padding; + border: 1px solid $tutor-toast-border; + border-radius: $tutor-toast-radius; + background: $tutor-toast-background; + box-shadow: $tutor-toast-shadow; + color: $tutor-toast-text; + font-family: $tutor-toast-font; + overflow: hidden; + cursor: grab; + + &:active { + cursor: grabbing; + } + + &[data-type='default'] { + background: $tutor-toast-default-background; + border-color: $tutor-toast-default-border; + color: $tutor-toast-default-text; + } + + &[data-type='success'] { + color: $tutor-toast-success-text; + } + + &[data-type='error'] { + color: $tutor-toast-error-text; + } + + &[data-type='warning'] { + color: $tutor-toast-warning-text; + } + + &[data-type='info'] { + color: $tutor-toast-info-text; + } + + &[data-type='loading'] { + color: $tutor-toast-loading-text; + } + + &[data-rich-colors][data-type='success'] { + background: $tutor-toast-success-background; + border-color: $tutor-toast-success-border; + } + + &[data-rich-colors][data-type='error'] { + background: $tutor-toast-error-background; + border-color: $tutor-toast-error-border; + } + + &[data-rich-colors][data-type='warning'] { + background: $tutor-toast-warning-background; + border-color: $tutor-toast-warning-border; + } + + &[data-rich-colors][data-type='info'] { + background: $tutor-toast-info-background; + border-color: $tutor-toast-info-border; + } + + &[data-rich-colors][data-type='loading'] { + background: $tutor-toast-loading-background; + border-color: $tutor-toast-loading-border; + } +} + +.tutor-toast-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + inline-size: $tutor-toast-icon-size; + block-size: $tutor-toast-icon-size; + border: 1px solid transparent; + border-radius: $tutor-radius-full; + background: $tutor-toast-default-background; + color: $tutor-toast-info-icon; + + .tutor-toast-card[data-type='success'] & { + color: $tutor-toast-success-icon; + border-color: $tutor-toast-success-border; + background: $tutor-toast-success-background; + } + + .tutor-toast-card[data-type='error'] & { + color: $tutor-toast-error-icon; + border-color: $tutor-toast-error-border; + background: $tutor-toast-error-background; + } + + .tutor-toast-card[data-type='warning'] & { + color: $tutor-toast-warning-icon; + border-color: $tutor-toast-warning-border; + background: $tutor-toast-warning-background; + } + + .tutor-toast-card[data-type='info'] & { + color: $tutor-toast-info-icon; + border-color: $tutor-toast-info-border; + background: $tutor-toast-info-background; + } + + .tutor-toast-card[data-type='loading'] & { + color: $tutor-toast-loading-icon; + border-color: $tutor-toast-loading-border; + background: $tutor-toast-loading-background; + } + + .tutor-toast-card[data-type='default'] & { + color: $tutor-toast-default-icon; + border-color: $tutor-toast-default-border; + background: $tutor-toast-default-background; + } +} + +.tutor-toast-content { + min-width: 0; + flex: 1; +} + +.tutor-toast-title, +.tutor-toast-description { + margin: 0; +} + +.tutor-toast-title { + @include tutor-typography('medium', 'medium'); + color: currentColor; +} + +.tutor-toast-description { + @include tutor-typography('tiny'); + color: $tutor-toast-text-muted; + + .tutor-toast-card[data-type='default'] & { + color: $tutor-toast-default-text; + } +} + +button.tutor-toast-close { + @include tutor-button-base(); + border: 0; + background: transparent; + color: $tutor-icon-idle; + cursor: pointer; +} + +button.tutor-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 28px; + block-size: 28px; + border-radius: $tutor-radius-full; + flex-shrink: 0; +} + +.tutor-toast-progress { + position: absolute; + inset-inline: 0; + inset-block-end: 0; + block-size: 3px; + background: color-mix(in srgb, currentColor 8%, transparent); + overflow: hidden; +} + +.tutor-toast-progress-bar { + block-size: 100%; + transform-origin: left; + + .tutor-toast-card[data-type='success'] & { + background: $tutor-toast-success-icon; + } + + .tutor-toast-card[data-type='error'] & { + background: $tutor-toast-error-icon; + } + + .tutor-toast-card[data-type='warning'] & { + background: $tutor-toast-warning-icon; + } + + .tutor-toast-card[data-type='info'] & { + background: $tutor-toast-info-icon; + } + + .tutor-toast-card[data-type='loading'] & { + background: $tutor-toast-loading-icon; + } + + .tutor-toast-card[data-type='default'] & { + background: color-mix(in srgb, $tutor-toast-default-text 30%, transparent); + } +} + +.tutor-toast-card[data-type='loading'] .tutor-toast-progress { + display: none; +} + +@keyframes tutor-toast-progress-shrink { + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +} + +.tutor-toast-spinner { + inline-size: 18px; + block-size: 18px; + border: 2px solid color-mix(in srgb, currentColor 20%, transparent); + border-top-color: currentColor; + border-radius: 999px; + animation: tutor-toast-spinner-rotate 800ms linear infinite; +} + +@keyframes tutor-toast-spinner-rotate { + to { + transform: rotate(360deg); + } +} + +[data-tutor-motion='reduce'] .tutor-toast-item { + animation: none !important; + transition: opacity 120ms ease !important; +} + +@media (prefers-reduced-motion: reduce) { + .tutor-toast-item { + animation: none !important; + transition: opacity 120ms ease !important; + } +} diff --git a/assets/core/scss/components/_tooltip.scss b/assets/core/scss/components/_tooltip.scss new file mode 100644 index 0000000000..40144c1238 --- /dev/null +++ b/assets/core/scss/components/_tooltip.scss @@ -0,0 +1,133 @@ +// Tooltip Component +// RTL-aware tooltip positioning and styling + +@use '../tokens' as *; +@use '../mixins' as *; +@use '../mixins/rtl' as rtl; + +.tutor-tooltip-wrap { + position: relative; + display: inline-block; + line-height: 0; +} + +.tutor-tooltip { + @include tutor-typography('tiny', 'regular', 'primary-inverse'); + position: fixed; + z-index: 1070; + max-width: 180px; + padding: $tutor-spacing-4; + background-color: $tutor-surface-dark; + border-radius: $tutor-radius-sm; + box-shadow: $tutor-shadow-md; + word-wrap: break-word; + text-align: center; + pointer-events: none; + + &-medium { + max-width: 226px; + padding: $tutor-spacing-5; + } + + &-large { + max-width: 320px; + padding: $tutor-spacing-5; + text-align: start; + } + + &-arrow-start { + &.tutor-tooltip-top, + &.tutor-tooltip-bottom { + &::before { + @include rtl.arrow-start($tutor-spacing-5); + } + } + } + + &-arrow-center { + &.tutor-tooltip-top, + &.tutor-tooltip-bottom { + &::before { + left: 50%; + right: auto; + transform: translateX(-50%); + } + } + } + + &-arrow-end { + &.tutor-tooltip-top, + &.tutor-tooltip-bottom { + &::before { + @include rtl.arrow-end($tutor-spacing-5); + } + } + } + + &::before { + content: ''; + position: absolute; + width: 0; + height: 0; + border: 6px solid transparent; + } + + &-top { + &::before { + top: 100%; + border-top-color: $tutor-surface-dark; + border-bottom: none; + } + } + + &-bottom { + &::before { + bottom: 100%; + border-bottom-color: $tutor-surface-dark; + border-top: none; + } + } + + &-start { + &::before { + top: 50%; + inset-inline-start: 100%; + border-inline-start-color: $tutor-surface-dark; + transform: translateY(-50%); + } + } + + &-end { + &::before { + top: 50%; + inset-inline-end: 100%; + border-inline-end-color: $tutor-surface-dark; + transform: translateY(-50%); + } + } +} + +// Animation classes for Alpine.js +.tutor-tooltip { + &-enter { + opacity: 0; + transform: scale(0.8); + } + + &-enter-active { + opacity: 1; + transform: scale(1); + @include tutor-transition((opacity, transform), 0.15s); + } + + &-leave { + opacity: 1; + transform: scale(1); + } + + &-leave-active { + opacity: 0; + transform: scale(0.8); + @include tutor-transition((opacity, transform), 0.15s); + } +} diff --git a/assets/core/scss/main.scss b/assets/core/scss/main.scss new file mode 100644 index 0000000000..fb0542ac17 --- /dev/null +++ b/assets/core/scss/main.scss @@ -0,0 +1,129 @@ +// Tutor Design System - Main Entry Point +// Single CSS file with all themes and RTL support + +// Import design tokens +@use 'tokens' as *; + +// Import themes +@use 'themes'; + +// Import mixins +@use 'mixins' as *; + +// Import base components +@use 'components'; + +// Import utilities +@use 'utilities'; + +// Base styles +// CSS Reset +*, +*::before, +*::after { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +html { + font-family: $tutor-font-family-body; + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + -moz-tab-size: 4; + tab-size: 4; +} + +body { + margin: 0; + font-family: inherit; + line-height: inherit; + background-color: $tutor-surface-base; + color: $tutor-text-primary; + @include tutor-transition((background-color, color)); +} + +form { + margin: $tutor-spacing-none; +} + +.tutor-dashboard-layout, +.tutor-learning-area, +.tutor-account-page-wrapper { + min-height: 100vh; + background-color: $tutor-surface-base; + + body:has(#wpadminbar) & { + min-height: calc(100vh - 32px); + } + + a { + text-decoration: none; + + &:hover { + opacity: 1; + } + } +} + +// Alpine.js cloak utility +[x-cloak] { + display: none !important; +} + +::view-transition-new(root) { + animation: tutor-theme-fade-in 220ms ease-out both; +} + +::view-transition-old(root) { + animation: tutor-theme-fade-out 220ms ease-out both; +} + +@keyframes tutor-theme-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes tutor-theme-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +// Reduce Motion +[data-tutor-motion='reduce'] *, +[data-tutor-motion='reduce'] *::before, +[data-tutor-motion='reduce'] *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; +} + +[data-tutor-motion='reduce']::view-transition-new(root), +[data-tutor-motion='reduce']::view-transition-old(root) { + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + [data-tutor-motion='auto'] *, + [data-tutor-motion='auto'] *::before, + [data-tutor-motion='auto'] *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } + + [data-tutor-motion='auto']::view-transition-new(root), + [data-tutor-motion='auto']::view-transition-old(root) { + animation: none !important; + } +} diff --git a/assets/core/scss/mixins/_avatars.scss b/assets/core/scss/mixins/_avatars.scss new file mode 100644 index 0000000000..0cbd431ba8 --- /dev/null +++ b/assets/core/scss/mixins/_avatars.scss @@ -0,0 +1,122 @@ +// Avatar Mixins +@use '../tokens' as *; +@use 'typography' as *; +@use 'layout' as *; + +@mixin tutor-avatar-base { + @include tutor-flex-center; + position: relative; + border-radius: $tutor-radius-full; + overflow: hidden; + background-color: $tutor-surface-brand-quaternary; + color: $tutor-text-primary; + flex-shrink: 0; + user-select: none; +} + +@mixin tutor-avatar-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + aspect-ratio: 1/1; +} + +@mixin tutor-avatar-initials { + @include tutor-typography('small', 'medium'); + color: inherit; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +// Avatar sizes map +$tutor-avatar-sizes: ( + 20: ( + font-size: 'tiny-2', + icon-size: 12px, + ), + 24: ( + font-size: 'tiny', + icon-size: 12px, + ), + 32: ( + font-size: 'tiny', + icon-size: 16px, + ), + 40: ( + font-size: 'small', + icon-size: 20px, + ), + 48: ( + font-size: 'small', + icon-size: 24px, + ), + 56: ( + font-size: 'medium', + icon-size: 28px, + ), + 64: ( + font-size: 'medium', + icon-size: 32px, + ), + 80: ( + font-size: $tutor-font-size-h4, + icon-size: 40px, + ), + 104: ( + font-size: $tutor-font-size-h3, + icon-size: 48px, + ), +); + +@mixin tutor-avatar-size($size) { + @if map-has-key($tutor-avatar-sizes, $size) { + $config: map-get($tutor-avatar-sizes, $size); + $font-size: map-get($config, font-size); + $icon-size: map-get($config, icon-size); + + width: #{$size}px; + height: #{$size}px; + + .tutor-avatar-initials { + @if type-of($font-size) == 'string' { + @include tutor-typography($font-size, 'medium'); + } @else { + font-size: $font-size; + font-weight: $tutor-font-weight-medium; + } + } + + svg { + width: $icon-size; + height: $icon-size; + } + } +} + +// Avatar variants +@mixin tutor-avatar-variant($variant: default) { + @if $variant == square { + border-radius: $tutor-radius-md; + + svg, + .tutor-avatar-image { + border-radius: $tutor-radius-md; + } + } @else if $variant == icon { + background-color: $tutor-surface-brand-tertiary; + color: $tutor-text-secondary; + + &:hover { + background-color: $tutor-actions-brand-tertiary; + color: $tutor-actions-brand-primary; + } + } @else if $variant == border { + border: 2px solid $tutor-border-brand-secondary; + svg, + .tutor-avatar-image { + border-radius: 0px; + } + } +} diff --git a/assets/core/scss/mixins/_badges.scss b/assets/core/scss/mixins/_badges.scss new file mode 100644 index 0000000000..3f838097aa --- /dev/null +++ b/assets/core/scss/mixins/_badges.scss @@ -0,0 +1,91 @@ +@use '../tokens' as *; +@use 'typography' as *; +@use 'layout' as *; + +@mixin tutor-badge-base() { + @include tutor-typography('tiny', 'medium', 'secondary'); + border-radius: $tutor-radius-sm; + padding-inline: $tutor-spacing-4; + display: inline-flex; + align-items: center; + gap: $tutor-spacing-3; + background-color: $tutor-actions-gray-secondary; + width: fit-content; + + img, + svg { + @include tutor-flex-center; + width: 12px; + height: 12px; + object-fit: contain; + object-position: center; + } + + svg { + color: $tutor-icon-idle; + } + + &.tutor-badge-rounded { + border-radius: $tutor-radius-full; + } +} + +@mixin tutor-badge-variant($variant: primary) { + @if $variant ==primary { + background-color: $tutor-actions-brand-primary; + color: $tutor-text-primary-inverse; + + svg { + color: $tutor-icon-idle-inverse; + } + } @else if $variant ==info { + background-color: $tutor-actions-brand-tertiary; + color: $tutor-text-brand; + + svg { + color: $tutor-icon-brand; + } + } @else if $variant ==warning { + background-color: $tutor-actions-warning-secondary; + color: $tutor-text-warning; + + svg { + color: $tutor-icon-warning; + } + } @else if $variant ==success { + background-color: $tutor-actions-success-tertiary; + color: $tutor-text-success; + + svg { + color: $tutor-icon-success-primary; + } + } @else if $variant ==success-solid { + background-color: $tutor-actions-success-primary; + color: $tutor-text-primary-inverse; + + svg { + color: $tutor-icon-idle-inverse; + } + } @else if $variant ==error { + background-color: $tutor-actions-critical-secondary; + color: $tutor-text-critical; + + svg { + color: $tutor-icon-critical; + } + } @else if $variant ==highlight { + background-color: $tutor-surface-exception2-secondary; + color: $tutor-text-exception2; + + svg { + color: $tutor-icon-exception2; + } + } @else if $variant ==disabled { + background-color: $tutor-actions-gray-secondary; + color: $tutor-text-subdued; + + svg { + color: $tutor-icon-secondary; + } + } +} diff --git a/assets/core/scss/mixins/_buttons.scss b/assets/core/scss/mixins/_buttons.scss new file mode 100644 index 0000000000..e638cec7bb --- /dev/null +++ b/assets/core/scss/mixins/_buttons.scss @@ -0,0 +1,504 @@ +// Button Mixins +// Reusable button styles with variants and sizes + +@use './utilities' as *; +@use '../tokens' as *; + +@mixin tutor-button-base { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $tutor-spacing-2; + border-radius: $tutor-button-radius; + font-weight: $tutor-button-font-weight; + cursor: pointer; + text-align: center; + text-decoration: none; + text-transform: none; + font-family: inherit; + background-color: transparent; + color: $tutor-text-primary; + padding: $tutor-spacing-none; + border: none; + // Inherited “kids mode” elevation + per-variant border shadow. + --tutor-button-border-shadow: none; + box-shadow: $tutor-button-border-shadow; + @include tutor-transition((color, background-color)); + + &:hover:not(:disabled):not(.disabled), + &:focus:not(:disabled):not(.disabled) { + background-color: transparent; + border: none; + outline: none; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-primary-border-shadow}; + } + + &:focus-visible:not(:disabled):not(.disabled) { + @include tutor-focus-ring; + } + + svg:not([class]) { + color: currentColor; + flex-shrink: 0; + } + + &:disabled, + &.disabled { + color: $tutor-text-subdued; + cursor: not-allowed; + pointer-events: none; + + svg:not([class]) { + color: $tutor-icon-subdued; + } + } +} + +@mixin tutor-button-variant($variant: primary) { + @if $variant == primary { + background-color: $tutor-button-primary; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-primary-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-idle-inverse; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-primary-hover; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-primary-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-primary-focused; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-primary-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-primary-disabled; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-primary-border-shadow-disabled}; + } + } @else if $variant == primary-soft { + background-color: $tutor-button-primary-soft; + color: $tutor-text-brand; + --tutor-button-border-shadow: #{$tutor-button-primary-soft-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-brand; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-primary-soft-hover; + color: $tutor-text-brand; + --tutor-button-border-shadow: #{$tutor-button-primary-soft-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-primary-soft-focused; + color: $tutor-text-brand; + --tutor-button-border-shadow: #{$tutor-button-primary-soft-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-disabled; + --tutor-button-border-shadow: #{$tutor-button-primary-soft-border-shadow-disabled}; + } + } @else if $variant == destructive { + background-color: $tutor-button-destructive; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-destructive-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-idle-inverse; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-destructive-hover; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-destructive-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-destructive-focused; + color: $tutor-text-primary-inverse; + --tutor-button-border-shadow: #{$tutor-button-destructive-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-disabled; + --tutor-button-border-shadow: #{$tutor-button-destructive-border-shadow-disabled}; + } + } @else if $variant == destructive-soft { + background-color: $tutor-button-destructive-soft; + color: $tutor-text-critical; + --tutor-button-border-shadow: #{$tutor-button-destructive-soft-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-critical; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-destructive-soft-hover; + color: $tutor-text-critical; + --tutor-button-border-shadow: #{$tutor-button-destructive-soft-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-destructive-soft-focused; + color: $tutor-text-critical; + --tutor-button-border-shadow: #{$tutor-button-destructive-soft-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-disabled; + --tutor-button-border-shadow: #{$tutor-button-destructive-soft-border-shadow-disabled}; + } + } @else if $variant == success { + background-color: $tutor-button-success; + color: $tutor-text-primary-inverse; + + svg:not([class]) { + color: $tutor-icon-idle-inverse; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-success-hover; + color: $tutor-text-primary-inverse; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-success-focused; + color: $tutor-text-primary-inverse; + } + } @else if $variant == secondary { + background-color: $tutor-button-secondary; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-secondary-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-idle; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-secondary-hover; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-secondary-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-secondary-focused; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-secondary-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-disabled; + --tutor-button-border-shadow: #{$tutor-button-secondary-border-shadow-disabled}; + } + } @else if $variant == outline { + background-color: $tutor-button-outline-inverse; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-outline-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-idle; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-outline-hover; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-outline-border-shadow-hover}; + } + + &:focus:not(:disabled):not(.disabled) { + background-color: $tutor-button-outline-focused-inverse; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-outline-border-shadow-focused}; + } + + &:disabled, + &.disabled { + background-color: $tutor-button-disabled; + --tutor-button-border-shadow: #{$tutor-button-outline-border-shadow-disabled}; + } + } @else if $variant == ghost { + background-color: transparent; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-ghost-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-idle; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-ghost-hover; + color: $tutor-text-primary; + --tutor-button-border-shadow: #{$tutor-button-ghost-border-shadow-hover}; + } + + &:disabled, + &.disabled { + background-color: transparent; + --tutor-button-border-shadow: #{$tutor-button-ghost-border-shadow-disabled}; + } + } @else if $variant == ghost-brand { + background-color: transparent; + color: $tutor-text-brand; + --tutor-button-border-shadow: #{$tutor-button-ghost-brand-border-shadow}; + + svg:not([class]) { + color: $tutor-icon-brand; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: $tutor-button-primary-soft; + color: $tutor-text-brand; + --tutor-button-border-shadow: #{$tutor-button-ghost-brand-border-shadow-hover}; + } + + &:disabled, + &.disabled { + background-color: transparent; + --tutor-button-border-shadow: #{$tutor-button-ghost-brand-border-shadow-disabled}; + } + } @else if $variant == link { + background-color: transparent; + color: $tutor-text-brand; + + svg:not([class]) { + color: $tutor-icon-brand; + } + + &:hover:not(:disabled):not(.disabled) { + color: $tutor-text-brand-hover; + box-shadow: none !important; + + svg:not([class]) { + color: $tutor-icon-brand-hover; + } + } + } @else if $variant == link-gray { + background-color: transparent; + color: $tutor-text-subdued; + + svg:not([class]) { + color: $tutor-icon-subdued; + } + + &:hover:not(:disabled):not(.disabled) { + color: $tutor-text-secondary; + + svg:not([class]) { + color: $tutor-icon-hover; + } + } + } @else if $variant == link-destructive { + background-color: transparent; + color: $tutor-text-critical; + + svg:not([class]) { + color: $tutor-icon-critical; + } + + &:hover:not(:disabled):not(.disabled) { + color: $tutor-text-critical-hover; + + svg:not([class]) { + color: $tutor-icon-critical-hover; + } + } + } +} + +@mixin tutor-button-size($size: medium) { + @if $size == x-small { + --tutor-button-border-shadow-base: 1px; + --tutor-button-border-shadow-bottom: 2px; + font-size: $tutor-font-size-tiny; + line-height: $tutor-line-height-tiny; + + &:not([class*='tutor-btn-link']) { + min-height: 32px; + padding: $tutor-spacing-3 $tutor-spacing-5; + } + } @else if $size == small { + --tutor-button-border-shadow-base: 1px; + --tutor-button-border-shadow-bottom: 2px; + font-size: $tutor-font-size-small; + line-height: $tutor-line-height-small; + + &:not([class*='tutor-btn-link']) { + padding: $tutor-spacing-3 $tutor-spacing-5; + min-height: 34px; + } + } @else if $size == large { + --tutor-button-border-shadow-base: 2px; + --tutor-button-border-shadow-bottom: 5px; + font-size: $tutor-font-size-small; + line-height: $tutor-line-height-small; + + &:not([class*='tutor-btn-link']) { + padding: $tutor-spacing-5 $tutor-spacing-6; + min-height: 48px; + } + } @else { + --tutor-button-border-shadow-base: 1px; + --tutor-button-border-shadow-bottom: 3px; + font-size: $tutor-font-size-small; + line-height: $tutor-line-height-small; + + &:not([class*='tutor-btn-link']) { + padding: $tutor-spacing-4 $tutor-spacing-5; + min-height: 38px; + } + } +} + +@mixin tutor-button-group($direction: horizontal) { + display: flex; + + @if $direction == horizontal { + flex-direction: row; + + .tutor-btn { + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } @else { + flex-direction: column; + + .tutor-btn { + &:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:not(:last-child) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + } +} + +@mixin tutor-button-block() { + width: 100%; + justify-content: center; +} + +@mixin tutor-button-icon-size($size) { + @if $size == x-small { + padding: $tutor-spacing-4; + min-width: 32px; + min-height: 32px; + } @else if $size == small { + padding: 9px; + min-width: 34px; + min-height: 34px; + } @else if $size == medium { + padding: 10px; + min-width: 40px; + min-height: 40px; + } @else if $size == large { + padding: 14px; + min-width: 48px; + min-height: 48px; + } +} + +@mixin tutor-button-loading { + position: relative; + pointer-events: none; + -webkit-text-fill-color: transparent; + + & > * { + visibility: hidden; + } + + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-radius: 50%; + border-top-color: transparent; + animation: tutor-button-spin 1s linear infinite; + + [data-tutor-motion='reduce'] & { + animation: tutor-button-pulse 1.5s ease-in-out infinite !important; + border-top-color: currentColor; + } + + @media (prefers-reduced-motion: reduce) { + [data-tutor-motion='auto'] & { + animation: tutor-button-pulse 1.5s ease-in-out infinite !important; + border-top-color: currentColor; + } + } + } +} + +// Keyframes for the standard spin +@keyframes tutor-button-spin { + 0% { + transform: translate(-50%, -50%) rotate(0deg); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +// Keyframes for the reduced motion "Pulse" +@keyframes tutor-button-pulse { + 0%, + 100% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + 50% { + opacity: 0.4; + transform: translate(-50%, -50%) scale(0.9); + } +} + +@mixin tutor-button-reset { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-decoration: none; + cursor: pointer; + outline: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + + &:focus { + outline: none; + } +} diff --git a/assets/core/scss/mixins/_cards.scss b/assets/core/scss/mixins/_cards.scss new file mode 100644 index 0000000000..43c4101699 --- /dev/null +++ b/assets/core/scss/mixins/_cards.scss @@ -0,0 +1,70 @@ +// Card Mixins +// Reusable card styles with elevation and variants + +@use '../tokens/' as *; +@use 'layout' as *; + +@mixin tutor-card-base { + background-color: $tutor-surface-l1; + border: 1px solid $tutor-border-idle; + box-shadow: none; +} + +@mixin tutor-card-padding($size: medium) { + @if $size == small { + padding: $tutor-spacing-4; + } @else if $size == large { + padding: $tutor-spacing-8; + } @else { + padding: $tutor-spacing-6; + } +} + +@mixin tutor-card-radius($size: lg) { + @if $size == none { + border-radius: $tutor-radius-none; + } @else if $size == xs { + border-radius: $tutor-radius-xs; + } @else if $size == sm { + border-radius: $tutor-radius-sm; + } @else if $size == md { + border-radius: $tutor-radius-md; + } @else if $size == lg { + border-radius: $tutor-radius-lg; + } @else if $size == 2xl { + border-radius: $tutor-radius-2xl; + } @else if $size == 3xl { + border-radius: $tutor-radius-3xl; + } @else if $size == 4xl { + border-radius: $tutor-radius-4xl; + } @else if $size == 5xl { + border-radius: $tutor-radius-5xl; + } @else if $size == full { + border-radius: $tutor-radius-full; + } @else { + border-radius: $tutor-radius-lg; // Default fallback + } +} + +// Card variants +@mixin tutor-card-variant($variant: default) { + @if $variant == outlined { + border: 1px solid $tutor-border-idle; + box-shadow: none; + } @else if $variant == filled { + background-color: $tutor-surface-l2; + border: none; + } @else if $variant == brand { + border: 1px solid $tutor-border-brand; + background-color: $tutor-brand-100; + } @else if $variant == success { + border: 1px solid $tutor-success; + background-color: $tutor-success-light; + } @else if $variant == warning { + border: 1px solid $tutor-warning; + background-color: $tutor-warning-light; + } @else if $variant == error { + border: 1px solid $tutor-error; + background-color: $tutor-error-light; + } +} diff --git a/assets/core/scss/mixins/_index.scss b/assets/core/scss/mixins/_index.scss new file mode 100644 index 0000000000..297bf33913 --- /dev/null +++ b/assets/core/scss/mixins/_index.scss @@ -0,0 +1,10 @@ +@forward "buttons"; +@forward "cards"; +@forward "inputs"; +@forward "layout"; +@forward "rtl"; +@forward "typography"; +@forward "utilities"; +@forward "badges"; +@forward "paginations"; +@forward "avatars"; diff --git a/assets/core/scss/mixins/_inputs.scss b/assets/core/scss/mixins/_inputs.scss new file mode 100644 index 0000000000..efd464fd37 --- /dev/null +++ b/assets/core/scss/mixins/_inputs.scss @@ -0,0 +1,274 @@ +@use '../tokens' as *; +@use 'typography' as *; +@use 'rtl' as *; +@use 'utilities' as *; +@use 'buttons' as *; + +@mixin tutor-input-base($size: md) { + @include tutor-typography('small', 'regular'); + width: 100%; + padding: 7px $tutor-spacing-5; + margin: 0; + min-height: auto; + border: 1px solid $tutor-border-idle; + border-color: $tutor-input-border-color; + border-radius: $tutor-input-radius; + background-color: $tutor-surface-l1; + font-weight: $tutor-input-font-weight; + --tutor-input-border-shadow-current: #{$tutor-input-border-shadow}; + box-shadow: var(--tutor-input-border-shadow-current); + @include tutor-transition(all); + + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button, + &::-webkit-search-cancel-button, + &::-webkit-autofill-button, + &::-webkit-contacts-auto-fill-button, + &::-webkit-credentials-auto-fill-button { + -webkit-appearance: none; + display: none; + visibility: hidden; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; + margin: 0; + } + + &:placeholder-shown:not(:focus):not(:disabled) { + background-color: $tutor-surface-l3; + } + + &::placeholder { + color: $tutor-text-subdued; + } + + &:focus { + @include tutor-focus-ring(); + background-color: $tutor-surface-l1; + border-color: $tutor-border-brand-tertiary; + color: $tutor-text-primary; + --tutor-input-border-shadow-current: none; + } + + &:disabled { + background-color: $tutor-surface-l1-hover; + cursor: not-allowed; + --tutor-input-border-shadow-current: none; + } + + @if $size == sm { + padding: 5px $tutor-spacing-4; + } + + @if $size == lg { + padding: $tutor-spacing-5; + } + + // Prevent iOS Safari from auto-zooming focused fields and exposing + // horizontal overflow on mobile layouts. + @media (hover: none) and (pointer: coarse) { + font-size: $tutor-font-size-medium; + line-height: $tutor-line-height-medium; + } +} + +@mixin tutor-textarea($min-height: 92px) { + @include tutor-input-base; + min-height: $min-height; + resize: vertical; +} + +@mixin tutor-checkbox($size: sm, $intermediate: false) { + $checkbox-size: if($size ==md, 20px, 16px); + $svg-size: if($size ==md, 12px, 10px); + + appearance: none; + width: $checkbox-size; + height: $checkbox-size; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-sm; + background-color: $tutor-surface-l1; + cursor: pointer; + position: relative; + flex-shrink: 0; + @include tutor-transition((background-color, border-color, box-shadow, color)); + + color: $tutor-icon-idle; + + &::before { + display: none !important; + } + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: $svg-size; + height: $svg-size; + transform: translate(-50%, -50%) scale(0.6); + opacity: 0; + @include tutor-transition((opacity, transform)); + + background-color: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M10.3 3.3a1 1 0 0 1 0 1.4L5.5 9.5a1 1 0 0 1-1.4 0L1.7 7.1a1 1 0 0 1 1.4-1.4L5 7.6l4.3-4.3a1 1 0 0 1 1.4 0z'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='M10.3 3.3a1 1 0 0 1 0 1.4L5.5 9.5a1 1 0 0 1-1.4 0L1.7 7.1a1 1 0 0 1 1.4-1.4L5 7.6l4.3-4.3a1 1 0 0 1 1.4 0z'/%3E%3C/svg%3E") + center / contain no-repeat; + } + + &:checked { + background-color: $tutor-surface-brand-primary; + border-color: transparent; + color: $tutor-icon-idle-inverse; + + &::after { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + } + + &:disabled { + background-color: $tutor-surface-l1-hover; + color: $tutor-icon-disabled; + cursor: not-allowed; + border-color: $tutor-border-idle; + } + + &:focus { + @include tutor-focus-ring; + + &:not(:checked) { + box-shadow: $tutor-ring-gray-shadow-sm; + } + } + + @if $intermediate ==true { + &::after { + mask: none; + -webkit-mask: none; + width: 7px; + height: 2.5px; + border-radius: $tutor-radius-full; + background-color: currentColor; + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + background-color: $tutor-surface-brand-primary; + border-color: transparent; + color: $tutor-icon-idle-inverse; + } +} + +@mixin tutor-radio($size: sm) { + $radio-size: if($size ==md, 20px, 16px); + $inner-circle: if($size ==md, 8px, 6px); + + appearance: none; + width: $radio-size; + height: $radio-size; + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-full; + background-color: $tutor-surface-l1; + cursor: pointer; + position: relative; + flex-shrink: 0; + @include tutor-transition((background-color, border-color, box-shadow)); + + &:checked { + background-color: $tutor-surface-brand-primary; + border-color: transparent; + + &::before { + display: none !important; + } + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: $inner-circle; + height: $inner-circle; + transform: translate(-50%, -50%); + border-radius: $tutor-radius-full; + background-color: $tutor-surface-l1; + } + } + + &:disabled { + background-color: $tutor-surface-l2; + cursor: not-allowed; + border-color: $tutor-border-idle; + } + + &:focus { + @include tutor-focus-ring; + + &:not(:checked) { + box-shadow: $tutor-ring-gray-shadow-sm; + } + } +} + +@mixin tutor-switch($size: sm, $intermediate: false) { + $width: if($size ==sm, 36px, 44px); + $height: if($size ==sm, 20px, 24px); + $thumb: if($size ==sm, 16px, 20px); + $gap: $tutor-spacing-1; + + appearance: none; + position: relative; + display: inline-flex; + align-items: center; + width: $width; + height: $height; + background-color: $tutor-button-secondary-hover; + border: none; + outline: none; + border-radius: $height; + cursor: pointer; + @include tutor-transition(background-color); + + &::before { + display: none !important; + } + + &::after { + content: ''; + position: absolute; + top: $gap; + left: $gap; + width: $thumb; + height: $thumb; + border-radius: 50%; + background-color: $tutor-actions-inverse; + box-shadow: $tutor-shadow-sm; + @include tutor-transition((transform, background-color)); + } + + &:focus { + @include tutor-focus-ring(); + border-color: $tutor-border-brand-tertiary; + } + + &:checked { + background-color: $tutor-button-primary; + + &::after { + transform: translateX(calc(#{$width} - #{$thumb} - #{$gap * 2})); + } + } + + &:disabled { + background-color: $tutor-button-disabled; + + &::after { + background-color: $tutor-actions-gray-empty; + } + } +} diff --git a/assets/core/scss/mixins/_layout.scss b/assets/core/scss/mixins/_layout.scss new file mode 100644 index 0000000000..9d0f63b74b --- /dev/null +++ b/assets/core/scss/mixins/_layout.scss @@ -0,0 +1,241 @@ +// Layout Mixins +// Reusable layout utilities for flexbox, grid, and positioning + +@use 'sass:map'; +@use '../tokens' as *; + +@mixin tutor-flex($direction: row, $align: stretch, $justify: flex-start, $wrap: nowrap) { + display: flex; + flex-direction: $direction; + align-items: $align; + justify-content: $justify; + flex-wrap: $wrap; +} + +@mixin tutor-flex-center { + @include tutor-flex(row, center, center); +} + +@mixin tutor-flex-between { + @include tutor-flex(row, center, space-between); +} + +@mixin tutor-flex-column { + @include tutor-flex(column, stretch, flex-start); +} + +@mixin tutor-grid($columns: 1, $gap: $tutor-spacing-6) { + display: grid; + grid-template-columns: repeat($columns, 1fr); + gap: $gap; +} + +@mixin tutor-container($max-width: 1200px) { + width: 100%; + max-width: $max-width; + margin: 0 auto; + padding: 0 $tutor-spacing-6; +} + +@mixin tutor-frontend-layout { + max-width: 760px; + margin: 0 auto; + + @include tutor-breakpoint-up(2xl) { + max-width: 860px; + } + + @include tutor-breakpoint-up(3xl) { + max-width: 944px; + } +} + +@mixin tutor-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +@mixin tutor-clearfix { + &::after { + content: ''; + display: table; + clear: both; + } +} + +// Responsive breakpoint mixin +@mixin tutor-breakpoint-up($breakpoint) { + @if map.has-key($tutor-breakpoints, $breakpoint) { + $value: map.get($tutor-breakpoints, $breakpoint); + + @if $value >0 { + @media (min-width: $value) { + @content; + } + } @else { + @content; + } + } @else { + @warn "Unknown breakpoint: #{$breakpoint}"; + } +} + +@mixin tutor-breakpoint-down($breakpoint) { + @if map.has-key($tutor-breakpoints, $breakpoint) { + $value: map.get($tutor-breakpoints, $breakpoint); + + @if $value >0 { + @media (max-width: $value) { + @content; + } + } @else { + @content; + } + } @else { + @warn "Unknown breakpoint: #{$breakpoint}"; + } +} + +@mixin tutor-breakpoint-between($breakpoint-1, $breakpoint-2) { + @include tutor-breakpoint-down($breakpoint-1) { + @include tutor-breakpoint-up($breakpoint-2) { + @content; + } + } +} + +// Advanced grid mixins +@mixin tutor-grid-auto-fit($min-width: 250px, $gap: $tutor-spacing-6) { + display: grid; + grid-template-columns: repeat(auto-fit, minmax($min-width, 1fr)); + gap: $gap; +} + +@mixin tutor-grid-auto-fill($min-width: 250px, $gap: $tutor-spacing-6) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($min-width, 1fr)); + gap: $gap; +} + +@mixin tutor-grid-auto-columns($min, $max, $selector: '*') { + display: grid; + gap: $tutor-spacing-6; + + grid-template-columns: repeat($min, 1fr); + + @for $i from ($min + 1) through $max { + // Count all children to increase column count; ignore drag clones. + &:has(> #{$selector}:nth-child(#{$i})) { + grid-template-columns: repeat($i, 1fr); + } + } + + // If a drag clone/placeholder is present, keep columns based on item count minus one. + &:has(> #{$selector}[data-dnd-dragging='true']) { + @for $i from ($min + 1) through ($max + 1) { + &:has(> #{$selector}:nth-child(#{$i})) { + grid-template-columns: repeat(#{$i - 1}, 1fr); + } + } + } +} + +@mixin tutor-grid-area($row-start, $col-start, $row-end: null, $col-end: null) { + @if $row-end and $col-end { + grid-area: $row-start / $col-start / $row-end / $col-end; + } @else { + grid-column-start: $col-start; + grid-row-start: $row-start; + } +} + +// Positioning mixins +@mixin tutor-absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +@mixin tutor-absolute-fill { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +@mixin tutor-sticky-top($offset: 0) { + position: sticky; + top: $offset; + z-index: 10; +} + +// Flexbox gap fallback for older browsers +@mixin tutor-flex-gap($gap) { + gap: $gap; + + // Fallback for browsers without gap support + @supports not (gap: 1px) { + margin: -#{$gap / 2}; + + > * { + margin: #{$gap / 2}; + } + } +} + +// Stack layout (vertical spacing) +@mixin tutor-stack($spacing: $tutor-spacing-4) { + > * + * { + margin-top: $spacing; + } +} + +// Cluster layout (horizontal spacing with wrapping) +@mixin tutor-cluster($spacing: $tutor-spacing-4, $align: flex-start) { + display: flex; + flex-wrap: wrap; + align-items: $align; + margin: -#{$spacing / 2}; + + > * { + margin: #{$spacing / 2}; + } +} + +// Sidebar layout +@mixin tutor-sidebar($sidebar-width: 250px, $gap: $tutor-spacing-6, $side: start) { + display: flex; + gap: $gap; + + @if $side ==start { + flex-direction: row; + + [dir='rtl'] & { + flex-direction: row-reverse; + } + } @else { + flex-direction: row-reverse; + + [dir='rtl'] & { + flex-direction: row; + } + } + + .tutor-sidebar { + flex: 0 0 $sidebar-width; + } + + .tutor-main { + flex: 1; + min-width: 0; // Prevent flex item from overflowing + } +} diff --git a/assets/core/scss/mixins/_paginations.scss b/assets/core/scss/mixins/_paginations.scss new file mode 100644 index 0000000000..cd6b645440 --- /dev/null +++ b/assets/core/scss/mixins/_paginations.scss @@ -0,0 +1,96 @@ +// Pagination Mixins +// Reusable Pagination styles with variants and sizes + +@use "../tokens" as *; +@use "utilities" as *; +@use "typography" as *; +@use "layout" as *; + +@mixin tutor-pagination-base { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: $tutor-spacing-4; + margin: $tutor-spacing-none; + padding: $tutor-spacing-none; +} + +@mixin tutor-pagination-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $tutor-spacing-1; + list-style: none; + margin: $tutor-spacing-none; + padding: $tutor-spacing-none; + li { + margin: $tutor-spacing-none; + padding: $tutor-spacing-none; + } +} + +@mixin tutor-pagination-info { + @include tutor-typography('small', 'regular'); + margin: 0; + .tutor-pagination-current, + .tutor-pagination-total { + font-weight: $tutor-font-weight-medium; + color: $tutor-text-primary; + } +} + +@mixin tutor-pagination-item { + @include tutor-flex-center; + padding: $tutor-spacing-3 $tutor-spacing-5; + border-radius: $tutor-radius-md; + text-decoration: none; + @include tutor-typography('tiny', 'medium'); + cursor: pointer; + min-width: 32px; + min-height: 32px; + @include tutor-transition((background-color, color)); + + // Default state + background-color: transparent; + + &.active, + &:active { + background-color: $tutor-button-primary-soft-focused; + color: $tutor-text-brand; + } + + &:hover { + background-color: $tutor-button-primary-soft-focused; + color: $tutor-text-brand; + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +@mixin tutor-pagination-item-variant($variant: default) { + @if $variant == active { + background-color: $tutor-button-primary-soft-focused; + color: $tutor-text-brand; + } @else if $variant == prev-next { + color: $tutor-icon-idle; + padding: $tutor-spacing-4; + + &:hover { + background-color: $tutor-actions-brand-tertiary; + color: $tutor-actions-brand-primary; + } + } +} + +@mixin tutor-pagination-ellipsis { + @include tutor-flex-center; + min-width: 32px; + min-height: 32px; + @include tutor-typography('tiny', 'medium'); + cursor: default; +} \ No newline at end of file diff --git a/assets/core/scss/mixins/_rtl.scss b/assets/core/scss/mixins/_rtl.scss new file mode 100644 index 0000000000..ce54601f13 --- /dev/null +++ b/assets/core/scss/mixins/_rtl.scss @@ -0,0 +1,135 @@ +// RTL (Right-to-Left) Mixins +// Provides directional-aware mixins for RTL language support + +// Directional margin mixins +@mixin margin-start($value) { + margin-inline-start: $value; +} +@mixin margin-end($value) { + margin-inline-end: $value; +} + +// Directional padding mixins +@mixin padding-start($value) { + padding-inline-start: $value; +} +@mixin padding-end($value) { + padding-inline-end: $value; +} + +// Directional border mixins +@mixin border-start($value) { + border-inline-start: $value; +} +@mixin border-end($value) { + border-inline-end: $value; +} +@mixin border-start-width($value) { + border-inline-start-width: $value; +} +@mixin border-end-width($value) { + border-inline-end-width: $value; +} +@mixin border-start-color($value) { + border-inline-start-color: $value; +} +@mixin border-end-color($value) { + border-inline-end-color: $value; +} + +// Directional border radius mixins +@mixin border-radius-start($value) { + border-start-start-radius: $value; + border-end-start-radius: $value; +} +@mixin border-radius-end($value) { + border-start-end-radius: $value; + border-end-end-radius: $value; +} +@mixin border-top-start-radius($value) { + border-start-start-radius: $value; +} +@mixin border-top-end-radius($value) { + border-start-end-radius: $value; +} +@mixin border-bottom-start-radius($value) { + border-end-start-radius: $value; +} +@mixin border-bottom-end-radius($value) { + border-end-end-radius: $value; +} + +// Directional positioning mixins +@mixin left($value) { + inset-inline-start: $value; +} +@mixin right($value) { + inset-inline-end: $value; +} + +// Text alignment mixins +@mixin text-align-start() { + text-align: start; +} +@mixin text-align-end() { + text-align: end; +} + +// Float mixins +@mixin float-start() { + float: inline-start; +} +@mixin float-end() { + float: inline-end; +} + +// Transform mixins for RTL-aware animations +@mixin transform-translate-x($value) { + transform: translateX($value); +} +@mixin transform-rotate-y($value) { + transform: rotateY($value); +} + +// Flexbox direction mixins +@mixin flex-direction-row() { + flex-direction: row; +} +@mixin flex-direction-row-reverse() { + flex-direction: row-reverse; +} + +// Background position mixins +@mixin background-position-x($value) { + background-position-inline-start: $value; +} + +// Component-specific RTL mixins +@mixin dropdown-position-start() { + inset-inline-start: 0; + inset-inline-end: auto; +} +@mixin dropdown-position-end() { + inset-inline-end: 0; + inset-inline-start: auto; +} + +// Icon positioning for buttons and inputs +@mixin icon-start($spacing: 4px) { + margin-inline-end: $spacing; + margin-inline-start: 0; +} +@mixin icon-end($spacing: 4px) { + margin-inline-start: $spacing; + margin-inline-end: 0; +} + +// Tooltip and popover arrow positioning +@mixin arrow-start($size: 8px) { + inset-inline-start: $size; + inset-inline-end: auto; +} +@mixin arrow-end($size: 8px) { + inset-inline-end: $size; + inset-inline-start: auto; +} diff --git a/assets/core/scss/mixins/_typography.scss b/assets/core/scss/mixins/_typography.scss new file mode 100644 index 0000000000..af1861b130 --- /dev/null +++ b/assets/core/scss/mixins/_typography.scss @@ -0,0 +1,47 @@ +@use 'sass:map'; +@use '../tokens' as *; + +@mixin tutor-typography($size: 'medium', $typeface: 'regular', $color: 'primary', $context: 'body') { + $font-size: if( + map.has-key($tutor-font-sizes, $size), + map.get($tutor-font-sizes, $size), + map.get($tutor-font-sizes, 'medium') + ); + $line-height: if( + map.has-key($tutor-line-heights, $size), + map.get($tutor-line-heights, $size), + map.get($tutor-line-heights, 'medium') + ); + $font-weight: if( + map.has-key($tutor-font-weights, $typeface), + map.get($tutor-font-weights, $typeface), + map.get($tutor-font-weights, 'regular') + ); + $color: if( + map.has-key($tutor-text-colors, $color), + map.get($tutor-text-colors, $color), + map.get($tutor-text-colors, 'primary') + ); + + font-family: if($context == 'heading', $tutor-font-family-heading, $tutor-font-family-body); + font-weight: $font-weight; + font-size: $font-size; + line-height: $line-height; + color: $color; +} + +@mixin tutor-text($size: 'medium') { + $font-size: if( + map.has-key($tutor-font-sizes, $size), + map.get($tutor-font-sizes, $size), + map.get($tutor-font-sizes, 'medium') + ); + $line-height: if( + map.has-key($tutor-line-heights, $size), + map.get($tutor-line-heights, $size), + map.get($tutor-line-heights, 'medium') + ); + + font-size: $font-size; + line-height: $line-height; +} diff --git a/assets/core/scss/mixins/_utilities.scss b/assets/core/scss/mixins/_utilities.scss new file mode 100644 index 0000000000..af254767e9 --- /dev/null +++ b/assets/core/scss/mixins/_utilities.scss @@ -0,0 +1,329 @@ +@use '../tokens' as *; + +@mixin tutor-reset { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; + background-color: transparent; + color: inherit; + text-decoration: none; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +@mixin tutor-text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin tutor-text-clamp($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@mixin tutor-scrollbar( + $width: 8px, + $thumb-color: $tutor-border-idle, + $thumb-hover-color: $tutor-icon-secondary, + $track-color: transparent, + $radius: $tutor-radius-full +) { + scrollbar-width: thin; + scrollbar-color: $thumb-color $track-color; + + &::-webkit-scrollbar { + width: $width; + } + + &::-webkit-scrollbar-track { + background: $track-color; + } + + &::-webkit-scrollbar-thumb { + background: $thumb-color; + border-radius: $radius; + + &:hover { + background: $thumb-hover-color; + } + } +} + +@mixin tutor-focus-ring($color: 'brand', $use-outline: false) { + $shadow: none; + $outline-color: $color; + + @if $color == 'brand' { + $shadow: $tutor-ring-brand-shadow-xs; + $outline-color: #90a0f7; + } + + @if $color == 'error' { + $shadow: $tutor-error-shadow-xs; + $outline-color: $tutor-error-shadow-xs; + } + + @if $use-outline { + outline: 2px solid $outline-color; + outline-offset: 2px; + } @else { + outline: none; + box-shadow: $shadow; + } +} + +@mixin tutor-transition($properties: all, $duration: 0.25s, $timing: ease-in-out, $delay: 0s) { + @if $properties == all { + transition: all $duration $timing $delay; + } @else { + $transitions: (); + + @each $property in $properties { + $transitions: append($transitions, $property $duration $timing $delay, comma); + } + + transition: $transitions; + } +} + +@mixin tutor-hover-lift { + @include tutor-transition(transform); + + &:hover { + transform: translateY(-2px); + } +} + +@mixin tutor-loading-skeleton { + background: linear-gradient(90deg, tutor-surface-l2 25%, tutor-surface-l1 50%, tutor-surface-l2 75%); + background-size: 200% 100%; + animation: tutor-skeleton-loading 1.5s infinite; +} + +@keyframes tutor-skeleton-loading { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +@mixin tutor-aspect-ratio($ratio: 16/9) { + aspect-ratio: $ratio; + + // Fallback for older browsers + @supports not (aspect-ratio: 1) { + position: relative; + + &::before { + content: ''; + display: block; + padding-top: percentage(1 / $ratio); + } + + > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } +} + +// Loading Spinner +@mixin tutor-loading-spinner($lineColor: #ababab, $lineBgColor: #f1f1f1, $size: 20px) { + &::before { + content: ''; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: #{$size}; + height: #{$size}; + margin-top: calc(-1 * #{$size} / 2); + margin-left: calc(-1 * #{$size} / 2); + border-radius: 50%; + border: 2px solid $lineBgColor; + border-top-color: $lineColor; + animation: tutor-spin 0.8s linear infinite; + } +} + +// Animation and Transition Mixins for consistent motion design +@mixin tutor-fade-in($duration: 0.3s, $timing: ease-out) { + opacity: 0; + animation: tutor-fade-in $duration $timing forwards; +} + +@mixin tutor-fade-out($duration: 0.3s, $timing: ease-in) { + animation: tutor-fade-out $duration $timing forwards; +} + +@mixin tutor-slide-in-up($duration: 0.3s, $timing: ease-out, $distance: 20px) { + transform: translateY($distance); + opacity: 0; + animation: tutor-slide-in-up $duration $timing forwards; +} + +@mixin tutor-slide-in-down($duration: 0.3s, $timing: ease-out, $distance: 20px) { + transform: translateY(-$distance); + opacity: 0; + animation: tutor-slide-in-down $duration $timing forwards; +} + +@mixin tutor-slide-in-left($duration: 0.3s, $timing: ease-out, $distance: 20px) { + transform: translateX(-$distance); + opacity: 0; + animation: tutor-slide-in-left $duration $timing forwards; +} + +@mixin tutor-slide-in-right($duration: 0.3s, $timing: ease-out, $distance: 20px) { + transform: translateX($distance); + opacity: 0; + animation: tutor-slide-in-right $duration $timing forwards; +} + +@mixin tutor-scale-in($duration: 0.3s, $timing: ease-out, $scale: 0.95) { + transform: scale($scale); + opacity: 0; + animation: tutor-scale-in $duration $timing forwards; +} + +@mixin tutor-bounce-in($duration: 0.6s) { + transform: scale(0.3); + opacity: 0; + animation: tutor-bounce-in $duration cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards; +} + +@mixin tutor-pulse($duration: 2s) { + animation: tutor-pulse $duration infinite; +} + +@mixin tutor-spin($duration: 1s) { + animation: tutor-spin $duration linear infinite; +} + +@mixin tutor-shake($duration: 0.5s) { + animation: tutor-shake $duration ease-in-out; +} + +// Keyframe animations +@keyframes tutor-fade-in { + to { + opacity: 1; + } +} + +@keyframes tutor-fade-out { + to { + opacity: 0; + } +} + +@keyframes tutor-slide-in-up { + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes tutor-slide-in-down { + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes tutor-slide-in-left { + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes tutor-slide-in-right { + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes tutor-scale-in { + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes tutor-bounce-in { + 0% { + transform: scale(0.3); + opacity: 0; + } + + 50% { + opacity: 1; + } + + 70% { + transform: scale(1.05); + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes tutor-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes tutor-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes tutor-shake { + 0%, + 100% { + transform: translateX(0); + } + + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-10px); + } + + 20%, + 40%, + 60%, + 80% { + transform: translateX(10px); + } +} diff --git a/assets/core/scss/themes/_dark.scss b/assets/core/scss/themes/_dark.scss new file mode 100644 index 0000000000..79efc7a6d4 --- /dev/null +++ b/assets/core/scss/themes/_dark.scss @@ -0,0 +1,240 @@ +// Dark Theme +// Alternative theme with dark color palette + +@use '../tokens' as *; + +// Mixin containing all dark theme tokens +@mixin dark-theme-tokens { + // ============================================================================= + // SURFACE COLORS + // ============================================================================= + + --tutor-surface-base: #{$tutor-gray-900}; + --tutor-surface-l1: #{$tutor-gray-800}; + --tutor-surface-l1-hover: #{$tutor-gray-750}; + --tutor-surface-l2: #{$tutor-gray-750}; + --tutor-surface-l2-hover: #{$tutor-gray-750}; + --tutor-surface-l3: #{$tutor-exception-7}; + --tutor-surface-brand-dark: #{$tutor-brand-950}; + --tutor-surface-brand-primary: #{$tutor-brand-600}; + --tutor-surface-brand-primary-2: #{$tutor-brand-800}; + --tutor-surface-brand-secondary: #{$tutor-brand-950}; + --tutor-surface-brand-tertiary: #{$tutor-gray-750}; + --tutor-surface-brand-quaternary: #{$tutor-brand-900}; + --tutor-surface-sidebar-l1: #{$tutor-gray-950}; + --tutor-surface-exception2-secondary: #{$tutor-gray-700}; + --tutor-surface-exception3-highlight: #{$tutor-exception-3}; + --tutor-surface-exception6: #{$tutor-exception-8}; + --tutor-surface-exception7: #{$tutor-brand-900}; + --tutor-surface-dark: #{$tutor-gray-600}; + --tutor-surface-warning: #{$tutor-gray-750}; + --tutor-surface-warning-hover: #{$tutor-warning-900}; + --tutor-surface-success: #{$tutor-success-950}; + --tutor-surface-critical: #{$tutor-error-950}; + + // ============================================================================= + // TEXT COLORS + // ============================================================================= + + --tutor-text-primary: #{$tutor-gray-100}; + --tutor-text-primary-inverse: #{$tutor-gray-1}; + --tutor-text-secondary: #{$tutor-gray-300}; + --tutor-text-subdued: #{$tutor-gray-500}; + --tutor-text-disabled: #{$tutor-gray-600}; + --tutor-text-brand: #{$tutor-brand-500}; + --tutor-text-brand-hover: #{$tutor-brand-500}; + --tutor-text-brand-secondary: #{$tutor-brand-400}; + --tutor-text-light: #{$tutor-gray-25}; + --tutor-text-success: #{$tutor-success-600}; + --tutor-text-critical: #{$tutor-error-600}; + --tutor-text-critical-hover: #{$tutor-error-700}; + --tutor-text-warning: #{$tutor-warning-600}; + --tutor-text-caution: #{$tutor-yellow-600}; + --tutor-text-exception1: #{$tutor-exception-1}; + --tutor-text-exception2: #{$tutor-exception-2}; + --tutor-text-exception4: #{$tutor-warning-400}; + --tutor-text-exception5: #{$tutor-exception-5}; + --tutor-text-highlighted-hover: #{$tutor-gray-700}; + + // ============================================================================= + // ICON COLORS + // ============================================================================= + + --tutor-icon-idle: #{$tutor-gray-300}; + --tutor-icon-idle-inverse: #{$tutor-gray-1}; + --tutor-icon-hover: #{$tutor-gray-1}; + --tutor-icon-secondary: #{$tutor-gray-500}; + --tutor-icon-subdued: #{$tutor-gray-600}; + --tutor-icon-disabled: #{$tutor-gray-600}; + --tutor-icon-brand: #{$tutor-brand-500}; + --tutor-icon-brand-hover: #{$tutor-brand-700}; + --tutor-icon-brand-secondary: #{$tutor-gray-750}; + --tutor-icon-exception1: #{$tutor-exception-1}; + --tutor-icon-exception2: #{$tutor-exception-2}; + --tutor-icon-exception4: #{$tutor-warning-400}; + --tutor-icon-exception5: #{$tutor-exception-5}; + --tutor-icon-success-primary: #{$tutor-success-700}; + --tutor-icon-success-secondary: #{$tutor-success-500}; + --tutor-icon-caution: #{$tutor-yellow-400}; + --tutor-icon-critical: #{$tutor-error-600}; + --tutor-icon-critical-hover: #{$tutor-error-700}; + --tutor-icon-warning: #{$tutor-warning-600}; + --tutor-icon-warning-secondary: #{$tutor-warning-400}; + + // ============================================================================= + // BUTTON COLORS + // ============================================================================= + + --tutor-button-primary: #{$tutor-brand-600}; + --tutor-button-primary-hover: #{$tutor-brand-700}; + --tutor-button-primary-focused: #{$tutor-brand-600}; + --tutor-button-primary-disabled: #{$tutor-brand-400}; + --tutor-button-primary-soft: #{$tutor-gray-700}; + --tutor-button-primary-soft-hover: #{$tutor-brand-400}; + --tutor-button-primary-soft-focused: #{$tutor-gray-700}; + --tutor-button-disabled: #{$tutor-gray-750}; + --tutor-button-destructive: #{$tutor-error-600}; + --tutor-button-destructive-hover: #{$tutor-error-700}; + --tutor-button-destructive-focused: #{$tutor-error-600}; + --tutor-button-destructive-soft: #{$tutor-error-100}; + --tutor-button-destructive-soft-hover: #{$tutor-error-200}; + --tutor-button-destructive-soft-focused: #{$tutor-error-100}; + --tutor-button-success: #{$tutor-success-500}; + --tutor-button-success-hover: #{$tutor-success-600}; + --tutor-button-success-focused: #{$tutor-success-500}; + --tutor-button-secondary: #{$tutor-gray-700}; + --tutor-button-secondary-hover: #{$tutor-gray-600}; + --tutor-button-secondary-focused: #{$tutor-gray-700}; + --tutor-button-outline-inverse: #{$tutor-gray-800}; + --tutor-button-outline-hover: #{$tutor-gray-700}; + --tutor-button-outline-focused-inverse: #{$tutor-gray-950}; + --tutor-button-ghost-hover: #{$tutor-gray-700}; + + // ============================================================================= + // BORDER COLORS + // ============================================================================= + + --tutor-border-idle: #{$tutor-gray-750}; + --tutor-border-idle2: #{$tutor-gray-700}; + --tutor-border-hover: #{$tutor-gray-600}; + --tutor-border-tertiary: #{$tutor-gray-600}; + --tutor-border-inverse: #{$tutor-gray-700}; + --tutor-border-brand: #{$tutor-brand-600}; + --tutor-border-brand-secondary: #{$tutor-gray-700}; + --tutor-border-brand-tertiary: #{$tutor-brand-700}; + --tutor-border-dark: #{$tutor-gray-700}; + --tutor-border-success: #{$tutor-success-700}; + --tutor-border-success-secondary: #{$tutor-success-600}; + --tutor-border-warning: #{$tutor-warning-200}; + --tutor-border-warning-secondary: #{$tutor-warning-600}; + --tutor-border-warning-tertiary: #{$tutor-warning-600}; + --tutor-border-error: #{$tutor-error-700}; + --tutor-border-error-secondary: #{$tutor-error-600}; + --tutor-border-error-tertiary: #{$tutor-error-400}; + --tutor-border-exception6: #{$tutor-exception-5}; + + // ============================================================================= + // TAB COLORS + // ============================================================================= + + --tutor-tab-sidebar-l2: #{$tutor-gray-1}; + --tutor-tab-sidebar-l2-hover: #{$tutor-gray-750}; + --tutor-tab-sidebar-l2-active: #{$tutor-gray-800}; + --tutor-tab-sidebar-l4-hover: #{$tutor-gray-750}; + --tutor-tab-sidebar-l4-active: #{$tutor-gray-750}; + --tutor-tab-l3: #{$tutor-gray-750}; + --tutor-tab-l3-hover: #{$tutor-gray-700}; + --tutor-tab-l3-active: #{$tutor-gray-750}; + --tutor-tab-l3-active-hover: #{$tutor-gray-750}; + + // ============================================================================= + // ACTION COLORS + // ============================================================================= + + --tutor-actions-success-primary: #{$tutor-success-600}; + --tutor-actions-success-secondary: #{$tutor-success-200}; + --tutor-actions-success-tertiary: #{$tutor-gray-700}; + --tutor-actions-success-exception: #{$tutor-success-950}; + --tutor-actions-warning-primary: #{$tutor-warning-400}; + --tutor-actions-warning-secondary: #{$tutor-gray-700}; + --tutor-actions-warning-tertiary: #{$tutor-warning-50}; + --tutor-actions-warning-exception: #{$tutor-warning-50}; + --tutor-actions-brand-primary: #{$tutor-brand-500}; + --tutor-actions-brand-secondary: #{$tutor-brand-950}; + --tutor-actions-brand-tertiary: #{$tutor-gray-750}; + --tutor-actions-gray-empty: #{$tutor-gray-800}; + --tutor-actions-gray-secondary: #{$tutor-gray-750}; + --tutor-actions-gray-tertiary: #{$tutor-gray-800}; + --tutor-actions-critical-primary: #{$tutor-error-500}; + --tutor-actions-critical-secondary: #{$tutor-gray-700}; + --tutor-actions-exception3-highlight: #{$tutor-exception-3}; + --tutor-actions-caution: #{$tutor-yellow-400}; + --tutor-actions-caution-secondary: #{$tutor-yellow-100}; + --tutor-actions-inverse: #{$tutor-gray-1}; + --tutor-actions-exception5: #{$tutor-exception-5}; + --tutor-actions-exception6: #{$tutor-exception-6}; + + // ============================================================================= + // QUIZ TOKENS + // ============================================================================= + + --tutor-quiz-idle: #{$tutor-gray-750}; + --tutor-quiz-hover: #{$tutor-gray-700}; + + // ============================================================================= + // VISUAL TOKENS + // ============================================================================= + + --tutor-visual-gray-1: #{$tutor-gray-800}; + --tutor-visual-gray-2: #{$tutor-gray-700}; + --tutor-visual-gray-3: #{$tutor-gray-600}; + --tutor-visual-gray-4: #{$tutor-yellow-800}; + --tutor-visual-brand-1: #{$tutor-brand-600}; + --tutor-visual-brand-2: #{$tutor-brand-800}; + --tutor-visual-brand-3: #{$tutor-brand-900}; + --tutor-visual-success-1: #{$tutor-success-500}; + --tutor-visual-success-2: #{$tutor-success-500}; + --tutor-visual-critical-1: #{$tutor-error-600}; + --tutor-visual-critical-2: #{$tutor-error-900}; + --tutor-visual-caution-1: #{$tutor-yellow-600}; + --tutor-visual-caution-2: #{$tutor-yellow-700}; + --tutor-visual-caution-3: #{$tutor-yellow-900}; + --tutor-visual-orange-1: #{$tutor-orange-400}; + --tutor-visual-exception-1: #{$tutor-exception-3}; + --tutor-visual-exception-2: #{$tutor-exception-9}; + --tutor-visual-exception-3: #{$tutor-exception-2}; + + // ============================================================================= + // TYPOGRAPHY TOKENS + // ============================================================================= + + --tutor-font-weight-regular: #{$tutor-font-weight-regular}; + --tutor-font-weight-medium: #{$tutor-font-weight-medium}; + --tutor-font-weight-semibold: #{$tutor-font-weight-semibold}; + --tutor-font-weight-bold: #{$tutor-font-weight-bold}; + + // ============================================================================= + // SHADOW TOKENS + // ============================================================================= + + --tutor-shadow-xs: #{$tutor-shadow-xs}; + --tutor-shadow-sm: #{$tutor-shadow-sm}; + --tutor-shadow-md: #{$tutor-shadow-md}; + --tutor-shadow-lg: #{$tutor-shadow-lg}; + --tutor-shadow-xl: #{$tutor-shadow-xl}; + --tutor-shadow-2xl: #{$tutor-shadow-2xl}; + --tutor-shadow-3xl: #{$tutor-shadow-3xl}; +} + +// Apply dark theme when explicitly set +[data-tutor-theme='dark'] { + @include dark-theme-tokens; +} + +// Apply dark theme when system preference is dark +// This prevents flash of light theme before JS loads +@media (prefers-color-scheme: dark) { + [data-tutor-theme='system'] { + @include dark-theme-tokens; + } +} diff --git a/assets/core/scss/themes/_deuteranomaly.scss b/assets/core/scss/themes/_deuteranomaly.scss new file mode 100644 index 0000000000..2aa8d15511 --- /dev/null +++ b/assets/core/scss/themes/_deuteranomaly.scss @@ -0,0 +1,150 @@ +// Deuteranomaly Theme +// CVD variant for users with Deuteranomaly (shifted green cone cells — most common form). +// Replaces red/error tokens with Orange palette; success tokens use Cyan where needed. +// Applies on top of the active light or dark base theme via data-tutor-vision. + +@use '../tokens' as *; + +// ============================================================================= +// SHARED — identical values in both light and dark +// ============================================================================= + +[data-tutor-vision='deuteranomaly'] { + // ICON COLORS + --tutor-icon-caution: #{$tutor-yellow-500}; + --tutor-icon-critical: #{$tutor-orange-600}; + --tutor-icon-critical-hover: #{$tutor-orange-700}; + --tutor-icon-warning: #{$tutor-orange-700}; + --tutor-icon-warning-secondary: #{$tutor-yellow-500}; + + // BUTTON COLORS + --tutor-button-destructive: #{$tutor-orange-600}; + --tutor-button-destructive-hover: #{$tutor-orange-700}; + --tutor-button-destructive-focused: #{$tutor-orange-600}; + --tutor-button-destructive-soft: #{$tutor-orange-100}; + --tutor-button-destructive-soft-hover: #{$tutor-orange-200}; + --tutor-button-destructive-soft-focused: #{$tutor-orange-100}; + + // BORDER COLORS + --tutor-border-warning: #{$tutor-yellow-300}; + --tutor-border-warning-secondary: #{$tutor-orange-700}; + --tutor-border-success: #{$tutor-cyan-400}; + --tutor-border-success-secondary: #{$tutor-cyan-600}; + --tutor-border-error-secondary: #{$tutor-orange-700}; + --tutor-border-error-tertiary: #{$tutor-orange-400}; + + // ACTION COLORS + --tutor-actions-success-primary: #{$tutor-cyan-500}; + --tutor-actions-success-secondary: #{$tutor-cyan-400}; + --tutor-actions-warning-primary: #{$tutor-orange-300}; + --tutor-actions-warning-tertiary: #{$tutor-orange-50}; + --tutor-actions-critical-primary: #{$tutor-orange-600}; + --tutor-actions-caution: #{$tutor-yellow-500}; + --tutor-actions-caution-secondary: #{$tutor-yellow-100}; + + // TEXT COLORS + --tutor-text-critical: #{$tutor-orange-600}; + --tutor-text-critical-hover: #{$tutor-orange-700}; + --tutor-text-warning: #{$tutor-orange-700}; + --tutor-text-caution: #{$tutor-yellow-700}; + + // VISUAL COLORS + --tutor-visual-success-1: #{$tutor-cyan-500}; + --tutor-visual-success-2: #{$tutor-cyan-500}; + --tutor-visual-exception-1: #{$tutor-cyan-200}; +} + +// ============================================================================= +// LIGHT — overrides specific to the light base theme +// ============================================================================= + +@mixin deuteranomaly-light-tokens { + // SURFACE COLORS + --tutor-surface-warning: #{$tutor-warning-50}; + --tutor-surface-warning-hover: #{$tutor-warning-100}; + --tutor-surface-success: #{$tutor-cyan-50}; + --tutor-surface-critical: #{$tutor-orange-100}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-success-700}; + + // ICON COLORS + --tutor-icon-success-primary: #{$tutor-success-600}; + --tutor-icon-success-secondary: #{$tutor-success-700}; + + // BUTTON COLORS + --tutor-button-success: #{$tutor-cyan-500}; + --tutor-button-success-hover: #{$tutor-cyan-600}; + --tutor-button-success-focused: #{$tutor-cyan-600}; + + // BORDER COLORS + --tutor-border-warning-tertiary: #{$tutor-orange-400}; + --tutor-border-error: #{$tutor-orange-300}; + + // ACTION COLORS + --tutor-actions-success-tertiary: #{$tutor-cyan-100}; + --tutor-actions-success-exception: #{$tutor-cyan-400}; + --tutor-actions-warning-secondary: #{$tutor-orange-100}; + --tutor-actions-warning-exception: #{$tutor-orange-600}; + --tutor-actions-critical-secondary: #{$tutor-orange-100}; + + // VISUAL COLORS + --tutor-visual-critical-1: #{$tutor-error-500}; + --tutor-visual-critical-2: #{$tutor-orange-100}; +} + +// ============================================================================= +// DARK — overrides specific to the dark base theme +// ============================================================================= + +@mixin deuteranomaly-dark-tokens { + // SURFACE COLORS + --tutor-surface-warning: #{$tutor-gray-750}; + --tutor-surface-warning-hover: #{$tutor-warning-900}; + --tutor-surface-success: #{$tutor-cyan-900}; + --tutor-surface-critical: #{$tutor-orange-900}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-cyan-400}; + + // ICON COLORS + --tutor-icon-success-primary: #{$tutor-cyan-500}; + --tutor-icon-success-secondary: #{$tutor-cyan-600}; + --tutor-icon-exception4: #{$tutor-warning-400}; + + // BUTTON COLORS + --tutor-button-success: #{$tutor-cyan-500}; + --tutor-button-success-hover: #{$tutor-cyan-600}; + --tutor-button-success-focused: #{$tutor-cyan-500}; + + // BORDER COLORS + --tutor-border-warning-tertiary: #{$tutor-orange-300}; + --tutor-border-success: #{$tutor-cyan-300}; + --tutor-border-error: #{$tutor-orange-300}; + + // ACTION COLORS + --tutor-actions-success-tertiary: #{$tutor-gray-700}; + --tutor-actions-success-exception: #{$tutor-cyan-950}; + --tutor-actions-warning-secondary: #{$tutor-gray-700}; + --tutor-actions-warning-exception: #{$tutor-orange-50}; + --tutor-actions-critical-secondary: #{$tutor-gray-700}; + + // VISUAL COLORS + --tutor-visual-critical-1: #{$tutor-error-600}; + --tutor-visual-critical-2: #{$tutor-orange-900}; +} + +[data-tutor-theme='light'][data-tutor-vision='deuteranomaly'], +[data-tutor-theme='system'][data-tutor-vision='deuteranomaly'] { + @include deuteranomaly-light-tokens; +} + +[data-tutor-theme='dark'][data-tutor-vision='deuteranomaly'] { + @include deuteranomaly-dark-tokens; +} + +@media (prefers-color-scheme: dark) { + [data-tutor-theme='system'][data-tutor-vision='deuteranomaly'] { + @include deuteranomaly-dark-tokens; + } +} diff --git a/assets/core/scss/themes/_deuteranopia.scss b/assets/core/scss/themes/_deuteranopia.scss new file mode 100644 index 0000000000..1b0a45a409 --- /dev/null +++ b/assets/core/scss/themes/_deuteranopia.scss @@ -0,0 +1,144 @@ +// Deuteranopia Theme +// CVD variant for users with Deuteranopia (absent green cone cells). +// Replaces green/success tokens with Cyan palette and red/error tokens with Orange palette. +// Applies on top of the active light or dark base theme via data-tutor-vision. + +@use '../tokens' as *; + +// ============================================================================= +// SHARED — identical values in both light and dark +// ============================================================================= + +[data-tutor-vision='deuteranopia'] { + // ICON COLORS + --tutor-icon-success-primary: #{$tutor-cyan-600}; + --tutor-icon-success-secondary: #{$tutor-cyan-700}; + --tutor-icon-caution: #{$tutor-yellow-500}; + --tutor-icon-critical: #{$tutor-orange-500}; + --tutor-icon-critical-hover: #{$tutor-orange-600}; + --tutor-icon-warning: #{$tutor-orange-700}; + --tutor-icon-warning-secondary: #{$tutor-yellow-500}; + + // BUTTON COLORS + --tutor-button-destructive: #{$tutor-orange-600}; + --tutor-button-destructive-hover: #{$tutor-orange-700}; + --tutor-button-destructive-focused: #{$tutor-orange-600}; + --tutor-button-destructive-soft: #{$tutor-orange-100}; + --tutor-button-destructive-soft-hover: #{$tutor-orange-200}; + --tutor-button-destructive-soft-focused: #{$tutor-orange-100}; + --tutor-button-success: #{$tutor-cyan-600}; + --tutor-button-success-hover: #{$tutor-cyan-700}; + --tutor-button-success-focused: #{$tutor-cyan-600}; + + // BORDER COLORS + --tutor-border-warning-tertiary: #{$tutor-orange-300}; + --tutor-border-success: #{$tutor-cyan-400}; + --tutor-border-success-secondary: #{$tutor-cyan-600}; + --tutor-border-error: #{$tutor-orange-400}; + --tutor-border-error-secondary: #{$tutor-orange-600}; + --tutor-border-error-tertiary: #{$tutor-orange-300}; + + // ACTION COLORS + --tutor-actions-warning-primary: #{$tutor-orange-300}; + --tutor-actions-warning-tertiary: #{$tutor-orange-50}; + --tutor-actions-caution: #{$tutor-yellow-400}; + --tutor-actions-caution-secondary: #{$tutor-yellow-100}; + + // TEXT COLORS + --tutor-text-critical: #{$tutor-orange-500}; + --tutor-text-warning: #{$tutor-orange-600}; + --tutor-text-caution: #{$tutor-yellow-600}; + + // VISUAL COLORS + --tutor-visual-success-1: #{$tutor-cyan-500}; + --tutor-visual-exception-1: #{$tutor-cyan-400}; +} + +// ============================================================================= +// LIGHT — overrides specific to the light base theme +// ============================================================================= + +@mixin deuteranopia-light-tokens { + // SURFACE COLORS + --tutor-surface-warning: #{$tutor-warning-50}; + --tutor-surface-warning-hover: #{$tutor-warning-100}; + --tutor-surface-success: #{$tutor-cyan-50}; + --tutor-surface-critical: #{$tutor-orange-100}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-cyan-700}; + --tutor-text-critical-hover: #{$tutor-warning-600}; + + // BORDER COLORS + --tutor-border-warning: #{$tutor-yellow-400}; + --tutor-border-warning-secondary: #{$tutor-orange-600}; + + // ACTION COLORS + --tutor-actions-success-primary: #{$tutor-cyan-600}; + --tutor-actions-success-secondary: #{$tutor-cyan-500}; + --tutor-actions-success-tertiary: #{$tutor-cyan-100}; + --tutor-actions-success-exception: #{$tutor-cyan-400}; + --tutor-actions-warning-secondary: #{$tutor-orange-100}; + --tutor-actions-warning-exception: #{$tutor-orange-500}; + --tutor-actions-critical-primary: #{$tutor-orange-500}; + --tutor-actions-critical-secondary: #{$tutor-orange-100}; + + // VISUAL COLORS + --tutor-visual-success-2: #{$tutor-cyan-500}; + --tutor-visual-critical-1: #{$tutor-orange-500}; + --tutor-visual-critical-2: #{$tutor-orange-300}; +} + +// ============================================================================= +// DARK — overrides specific to the dark base theme +// ============================================================================= + +@mixin deuteranopia-dark-tokens { + // SURFACE COLORS + --tutor-surface-warning: #{$tutor-gray-750}; + --tutor-surface-warning-hover: #{$tutor-warning-900}; + --tutor-surface-success: #{$tutor-cyan-900}; + --tutor-surface-critical: #{$tutor-orange-900}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-cyan-400}; + --tutor-text-critical-hover: #{$tutor-orange-600}; + + // ICON COLORS + --tutor-icon-exception4: #{$tutor-warning-400}; + + // BORDER COLORS + --tutor-border-warning: #{$tutor-yellow-300}; + --tutor-border-warning-secondary: #{$tutor-orange-700}; + + // ACTION COLORS + --tutor-actions-success-primary: #{$tutor-cyan-500}; + --tutor-actions-success-secondary: #{$tutor-cyan-400}; + --tutor-actions-success-tertiary: #{$tutor-gray-700}; + --tutor-actions-success-exception: #{$tutor-cyan-950}; + --tutor-actions-warning-secondary: #{$tutor-gray-700}; + --tutor-actions-warning-exception: #{$tutor-orange-50}; + --tutor-actions-critical-primary: #{$tutor-orange-600}; + --tutor-actions-critical-secondary: #{$tutor-gray-700}; + --tutor-actions-caution: #{$tutor-yellow-500}; + + // VISUAL COLORS + --tutor-visual-success-2: #{$tutor-cyan-600}; + --tutor-visual-critical-1: #{$tutor-orange-600}; + --tutor-visual-critical-2: #{$tutor-orange-900}; +} + +[data-tutor-theme='light'][data-tutor-vision='deuteranopia'], +[data-tutor-theme='system'][data-tutor-vision='deuteranopia'] { + @include deuteranopia-light-tokens; +} + +[data-tutor-theme='dark'][data-tutor-vision='deuteranopia'] { + @include deuteranopia-dark-tokens; +} + +@media (prefers-color-scheme: dark) { + [data-tutor-theme='system'][data-tutor-vision='deuteranopia'] { + @include deuteranopia-dark-tokens; + } +} diff --git a/assets/core/scss/themes/_high-contrast.scss b/assets/core/scss/themes/_high-contrast.scss new file mode 100644 index 0000000000..0a9ff83b73 --- /dev/null +++ b/assets/core/scss/themes/_high-contrast.scss @@ -0,0 +1,89 @@ +// High Contrast Theme +// Accessibility variant with increased contrast ratios for users who need +// higher foreground/background differentiation. +// Applies on top of the active light or dark base theme via data-tutor-contrast. + +@use '../tokens' as *; + +// ============================================================================= +// LIGHT — overrides specific to the light base theme +// ============================================================================= + +@mixin high-contrast-light-tokens { + // TEXT COLORS + --tutor-text-primary: #{$tutor-gray-950}; + --tutor-text-secondary: #{$tutor-gray-950}; + --tutor-text-subdued: #{$tutor-gray-950}; + --tutor-text-disabled: #{$tutor-gray-500}; + + // ICON COLORS + --tutor-icon-idle: #{$tutor-gray-750}; + --tutor-icon-hover: #{$tutor-gray-900}; + --tutor-icon-secondary: #{$tutor-gray-700}; + --tutor-icon-subdued: #{$tutor-gray-700}; + --tutor-icon-disabled: #{$tutor-gray-400}; + + // BORDER COLORS + --tutor-border-idle: #{$tutor-gray-800}; + --tutor-border-hover: #{$tutor-gray-900}; + --tutor-border-tertiary: #{$tutor-gray-800}; + --tutor-border-brand: #{$tutor-brand-600}; + --tutor-border-brand-secondary: #{$tutor-brand-800}; + --tutor-border-brand-tertiary: #{$tutor-brand-800}; + + // VISUAL COLORS + --tutor-visual-success-1: #{$tutor-success-500}; + --tutor-visual-success-2: #{$tutor-success-500}; + --tutor-visual-critical-1: #{$tutor-error-500}; + --tutor-visual-critical-2: #{$tutor-error-100}; + --tutor-visual-exception-1: #{$tutor-exception-3}; +} + +// ============================================================================= +// DARK — overrides specific to the dark base theme +// ============================================================================= + +@mixin high-contrast-dark-tokens { + // TEXT COLORS + --tutor-text-primary: #{$tutor-gray-100}; + --tutor-text-secondary: #{$tutor-gray-50}; + --tutor-text-subdued: #{$tutor-gray-50}; + --tutor-text-disabled: #{$tutor-gray-300}; + + // ICON COLORS + --tutor-icon-idle: #{$tutor-gray-300}; + --tutor-icon-hover: #{$tutor-gray-1}; + --tutor-icon-secondary: #{$tutor-gray-500}; + --tutor-icon-subdued: #{$tutor-gray-600}; + --tutor-icon-disabled: #{$tutor-gray-600}; + + // BORDER COLORS + --tutor-border-idle: #{$tutor-gray-400}; + --tutor-border-hover: #{$tutor-gray-300}; + --tutor-border-tertiary: #{$tutor-gray-300}; + --tutor-border-brand: #{$tutor-brand-200}; + --tutor-border-brand-secondary: #{$tutor-brand-800}; + --tutor-border-brand-tertiary: #{$tutor-brand-700}; + + // VISUAL COLORS + --tutor-visual-success-1: #{$tutor-success-500}; + --tutor-visual-success-2: #{$tutor-success-500}; + --tutor-visual-critical-1: #{$tutor-error-600}; + --tutor-visual-critical-2: #{$tutor-error-900}; + --tutor-visual-exception-1: #{$tutor-exception-3}; +} + +[data-tutor-theme='light'][data-tutor-contrast='high'], +[data-tutor-theme='system'][data-tutor-contrast='high'] { + @include high-contrast-light-tokens; +} + +[data-tutor-theme='dark'][data-tutor-contrast='high'] { + @include high-contrast-dark-tokens; +} + +@media (prefers-color-scheme: dark) { + [data-tutor-theme='system'][data-tutor-contrast='high'] { + @include high-contrast-dark-tokens; + } +} diff --git a/assets/core/scss/themes/_index.scss b/assets/core/scss/themes/_index.scss new file mode 100644 index 0000000000..313e109e95 --- /dev/null +++ b/assets/core/scss/themes/_index.scss @@ -0,0 +1,6 @@ +@forward "light"; +@forward "dark"; +@forward "high-contrast"; +@forward "protanopia"; +@forward "deuteranopia"; +@forward "deuteranomaly"; diff --git a/assets/core/scss/themes/_light.scss b/assets/core/scss/themes/_light.scss new file mode 100644 index 0000000000..c39654bd56 --- /dev/null +++ b/assets/core/scss/themes/_light.scss @@ -0,0 +1,227 @@ +// Light Theme +// Default theme with light color palette + +@use '../tokens' as *; + +:root, +[data-tutor-theme='light'] { + // ============================================================================= + // SURFACE COLORS + // ============================================================================= + + --tutor-surface-base: #{$tutor-gray-25}; + --tutor-surface-l1: #{$tutor-gray-1}; + --tutor-surface-l1-hover: #{$tutor-gray-50}; + --tutor-surface-l2: #{$tutor-gray-200}; + --tutor-surface-l2-hover: #{$tutor-gray-300}; + --tutor-surface-l3: #{$tutor-gray-50}; + --tutor-surface-brand-dark: #{$tutor-brand-950}; + --tutor-surface-brand-primary: #{$tutor-brand-600}; + --tutor-surface-brand-primary-2: #{$tutor-brand-800}; + --tutor-surface-brand-secondary: #{$tutor-brand-300}; + --tutor-surface-brand-tertiary: #{$tutor-brand-100}; + --tutor-surface-brand-quaternary: #{$tutor-brand-200}; + --tutor-surface-sidebar-l1: #{$tutor-gray-10}; + --tutor-surface-exception2-secondary: #{$tutor-exception-2-secondary}; + --tutor-surface-exception3-highlight: #{$tutor-exception-3}; + --tutor-surface-exception6: #{$tutor-exception-6}; + --tutor-surface-exception7: #{$tutor-brand-900}; + --tutor-surface-dark: #{$tutor-gray-950}; + --tutor-surface-warning: #{$tutor-warning-50}; + --tutor-surface-warning-hover: #{$tutor-warning-100}; + --tutor-surface-success: #{$tutor-success-50}; + --tutor-surface-critical: #{$tutor-error-100}; + + // ============================================================================= + // TEXT COLORS + // ============================================================================= + + --tutor-text-primary: #{$tutor-gray-950}; + --tutor-text-primary-inverse: #{$tutor-gray-1}; + --tutor-text-secondary: #{$tutor-gray-700}; + --tutor-text-subdued: #{$tutor-gray-500}; + --tutor-text-disabled: #{$tutor-gray-300}; + --tutor-text-brand: #{$tutor-brand-600}; + --tutor-text-brand-hover: #{$tutor-brand-700}; + --tutor-text-brand-secondary: #{$tutor-brand-400}; + --tutor-text-light: #{$tutor-gray-25}; + --tutor-text-success: #{$tutor-success-700}; + --tutor-text-critical: #{$tutor-error-600}; + --tutor-text-critical-hover: #{$tutor-error-700}; + --tutor-text-warning: #{$tutor-warning-800}; + --tutor-text-caution: #{$tutor-yellow-800}; + --tutor-text-exception1: #{$tutor-exception-1}; + --tutor-text-exception2: #{$tutor-exception-2}; + --tutor-text-exception4: #{$tutor-warning-400}; + --tutor-text-exception5: #{$tutor-exception-5}; + --tutor-text-highlighted-hover: #{$tutor-gray-700}; + + // ============================================================================= + // ICON COLORS + // ============================================================================= + + --tutor-icon-idle: #{$tutor-gray-700}; + --tutor-icon-idle-inverse: #{$tutor-gray-1}; + --tutor-icon-hover: #{$tutor-gray-900}; + --tutor-icon-secondary: #{$tutor-gray-500}; + --tutor-icon-subdued: #{$tutor-gray-400}; + --tutor-icon-disabled: #{$tutor-gray-300}; + --tutor-icon-brand: #{$tutor-brand-600}; + --tutor-icon-brand-hover: #{$tutor-brand-700}; + --tutor-icon-brand-secondary: #{$tutor-brand-300}; + --tutor-icon-exception1: #{$tutor-exception-1}; + --tutor-icon-exception2: #{$tutor-exception-2}; + --tutor-icon-exception4: #{$tutor-warning-400}; + --tutor-icon-exception5: #{$tutor-exception-5}; + --tutor-icon-success-primary: #{$tutor-success-700}; + --tutor-icon-success-secondary: #{$tutor-success-600}; + --tutor-icon-caution: #{$tutor-yellow-400}; + --tutor-icon-critical: #{$tutor-error-600}; + --tutor-icon-critical-hover: #{$tutor-error-700}; + --tutor-icon-warning: #{$tutor-warning-700}; + --tutor-icon-warning-secondary: #{$tutor-warning-400}; + + // ============================================================================= + // BUTTON COLORS + // ============================================================================= + + --tutor-button-primary: #{$tutor-brand-600}; + --tutor-button-primary-hover: #{$tutor-brand-700}; + --tutor-button-primary-focused: #{$tutor-brand-600}; + --tutor-button-primary-disabled: #{$tutor-brand-400}; + --tutor-button-primary-soft: #{$tutor-brand-200}; + --tutor-button-primary-soft-hover: #{$tutor-brand-300}; + --tutor-button-primary-soft-focused: #{$tutor-brand-200}; + --tutor-button-disabled: #{$tutor-gray-100}; + --tutor-button-destructive: #{$tutor-error-600}; + --tutor-button-destructive-hover: #{$tutor-error-700}; + --tutor-button-destructive-focused: #{$tutor-error-600}; + --tutor-button-destructive-soft: #{$tutor-error-100}; + --tutor-button-destructive-soft-hover: #{$tutor-error-200}; + --tutor-button-destructive-soft-focused: #{$tutor-error-100}; + --tutor-button-success: #{$tutor-success-600}; + --tutor-button-success-hover: #{$tutor-success-700}; + --tutor-button-success-focused: #{$tutor-success-600}; + --tutor-button-secondary: #{$tutor-gray-200}; + --tutor-button-secondary-hover: #{$tutor-gray-300}; + --tutor-button-secondary-focused: #{$tutor-gray-200}; + --tutor-button-outline-inverse: #{$tutor-gray-1}; + --tutor-button-outline-hover: #{$tutor-gray-200}; + --tutor-button-outline-focused-inverse: #{$tutor-gray-1}; + --tutor-button-ghost-hover: #{$tutor-gray-200}; + + // ============================================================================= + // BORDER COLORS + // ============================================================================= + + --tutor-border-idle: #{$tutor-gray-200}; + --tutor-border-idle2: #{$tutor-gray-200}; + --tutor-border-hover: #{$tutor-gray-300}; + --tutor-border-tertiary: #{$tutor-gray-400}; + --tutor-border-inverse: #{$tutor-gray-1}; + --tutor-border-brand: #{$tutor-brand-600}; + --tutor-border-brand-secondary: #{$tutor-brand-300}; + --tutor-border-brand-tertiary: #{$tutor-brand-400}; + --tutor-border-dark: #{$tutor-gray-950}; + --tutor-border-success: #{$tutor-success-300}; + --tutor-border-success-secondary: #{$tutor-success-700}; + --tutor-border-warning: #{$tutor-warning-200}; + --tutor-border-warning-secondary: #{$tutor-warning-700}; + --tutor-border-warning-tertiary: #{$tutor-warning-400}; + --tutor-border-error: #{$tutor-error-300}; + --tutor-border-error-secondary: #{$tutor-error-700}; + --tutor-border-error-tertiary: #{$tutor-error-400}; + --tutor-border-exception6: #{$tutor-exception-5}; + + // ============================================================================= + // TAB COLORS + // ============================================================================= + + --tutor-tab-sidebar-l2: #{$tutor-gray-10}; + --tutor-tab-sidebar-l2-hover: #{$tutor-gray-200}; + --tutor-tab-sidebar-l2-active: #{$tutor-brand-200}; + --tutor-tab-sidebar-l4-hover: #{$tutor-gray-200}; + --tutor-tab-sidebar-l4-active: #{$tutor-brand-200}; + --tutor-tab-l3: #{$tutor-brand-200}; + --tutor-tab-l3-hover: #{$tutor-brand-300}; + --tutor-tab-l3-active: #{$tutor-brand-200}; + --tutor-tab-l3-active-hover: #{$tutor-gray-100}; + + // ============================================================================= + // ACTION COLORS + // ============================================================================= + + --tutor-actions-success-primary: #{$tutor-success-600}; + --tutor-actions-success-secondary: #{$tutor-success-200}; + --tutor-actions-success-tertiary: #{$tutor-success-50}; + --tutor-actions-success-exception: #{$tutor-success-500}; + --tutor-actions-warning-primary: #{$tutor-warning-400}; + --tutor-actions-warning-secondary: #{$tutor-warning-100}; + --tutor-actions-warning-tertiary: #{$tutor-warning-50}; + --tutor-actions-warning-exception: #{$tutor-warning-500}; + --tutor-actions-brand-primary: #{$tutor-brand-600}; + --tutor-actions-brand-secondary: #{$tutor-brand-300}; + --tutor-actions-brand-tertiary: #{$tutor-brand-200}; + --tutor-actions-gray-empty: #{$tutor-gray-50}; + --tutor-actions-gray-secondary: #{$tutor-gray-100}; + --tutor-actions-gray-tertiary: #{$tutor-gray-200}; + --tutor-actions-critical-primary: #{$tutor-error-500}; + --tutor-actions-critical-secondary: #{$tutor-error-100}; + --tutor-actions-exception3-highlight: #{$tutor-exception-3}; + --tutor-actions-caution: #{$tutor-yellow-400}; + --tutor-actions-caution-secondary: #{$tutor-yellow-100}; + --tutor-actions-inverse: #{$tutor-gray-1}; + --tutor-actions-exception5: #{$tutor-exception-5}; + --tutor-actions-exception6: #{$tutor-exception-6}; + + // ============================================================================= + // QUIZ TOKENS + // ============================================================================= + + --tutor-quiz-idle: #{$tutor-gray-25}; + --tutor-quiz-hover: #{$tutor-gray-100}; + + // ============================================================================= + // VISUAL TOKENS + // ============================================================================= + + --tutor-visual-gray-1: #{$tutor-gray-1}; + --tutor-visual-gray-2: #{$tutor-gray-200}; + --tutor-visual-gray-3: #{$tutor-gray-300}; + --tutor-visual-gray-4: #{$tutor-gray-750}; + --tutor-visual-brand-1: #{$tutor-brand-500}; + --tutor-visual-brand-2: #{$tutor-brand-400}; + --tutor-visual-brand-3: #{$tutor-brand-300}; + --tutor-visual-success-1: #{$tutor-success-500}; + --tutor-visual-success-2: #{$tutor-success-500}; + --tutor-visual-critical-1: #{$tutor-error-500}; + --tutor-visual-critical-2: #{$tutor-error-100}; + --tutor-visual-caution-1: #{$tutor-yellow-300}; + --tutor-visual-caution-2: #{$tutor-yellow-700}; + --tutor-visual-caution-3: #{$tutor-yellow-900}; + --tutor-visual-orange-1: #{$tutor-orange-400}; + --tutor-visual-exception-1: #{$tutor-exception-3}; + --tutor-visual-exception-2: #{$tutor-exception-9}; + --tutor-visual-exception-3: #{$tutor-exception-2}; + + // ============================================================================= + // TYPOGRAPHY TOKENS + // ============================================================================= + + --tutor-font-weight-regular: #{$tutor-font-weight-regular}; + --tutor-font-weight-medium: #{$tutor-font-weight-medium}; + --tutor-font-weight-semibold: #{$tutor-font-weight-semibold}; + --tutor-font-weight-bold: #{$tutor-font-weight-bold}; + + // ============================================================================= + // SHADOW TOKENS + // ============================================================================= + + --tutor-shadow-xs: #{$tutor-shadow-xs}; + --tutor-shadow-sm: #{$tutor-shadow-sm}; + --tutor-shadow-md: #{$tutor-shadow-md}; + --tutor-shadow-lg: #{$tutor-shadow-lg}; + --tutor-shadow-xl: #{$tutor-shadow-xl}; + --tutor-shadow-2xl: #{$tutor-shadow-2xl}; + --tutor-shadow-3xl: #{$tutor-shadow-3xl}; +} diff --git a/assets/core/scss/themes/_protanopia.scss b/assets/core/scss/themes/_protanopia.scss new file mode 100644 index 0000000000..2f310ce708 --- /dev/null +++ b/assets/core/scss/themes/_protanopia.scss @@ -0,0 +1,122 @@ +// Protanopia Theme +// CVD variant for users with Protanopia (absent red cone cells). +// Replaces green/success tokens with Cyan palette and red/error tokens with Orange palette. +// Applies on top of the active light or dark base theme via data-tutor-vision. + +@use '../tokens' as *; + +// ============================================================================= +// SHARED — identical values in both light and dark +// ============================================================================= + +[data-tutor-vision='protanopia'] { + // ICON COLORS + --tutor-icon-success-primary: #{$tutor-cyan-500}; + --tutor-icon-success-secondary: #{$tutor-cyan-600}; + --tutor-icon-caution: #{$tutor-yellow-500}; + --tutor-icon-critical: #{$tutor-orange-600}; + --tutor-icon-critical-hover: #{$tutor-orange-700}; + --tutor-icon-warning: #{$tutor-orange-700}; + --tutor-icon-warning-secondary: #{$tutor-yellow-500}; + + // BUTTON COLORS + --tutor-button-destructive: #{$tutor-orange-600}; + --tutor-button-destructive-hover: #{$tutor-orange-700}; + --tutor-button-destructive-focused: #{$tutor-orange-600}; + --tutor-button-destructive-soft: #{$tutor-orange-100}; + --tutor-button-destructive-soft-hover: #{$tutor-orange-200}; + --tutor-button-destructive-soft-focused: #{$tutor-orange-100}; + --tutor-button-success: #{$tutor-cyan-500}; + --tutor-button-success-hover: #{$tutor-cyan-600}; + --tutor-button-success-focused: #{$tutor-cyan-500}; + + // BORDER COLORS + --tutor-border-warning: #{$tutor-yellow-300}; + --tutor-border-warning-secondary: #{$tutor-orange-700}; + --tutor-border-warning-tertiary: #{$tutor-orange-300}; + --tutor-border-success: #{$tutor-cyan-300}; + --tutor-border-success-secondary: #{$tutor-cyan-600}; + --tutor-border-error: #{$tutor-orange-300}; + --tutor-border-error-secondary: #{$tutor-orange-700}; + --tutor-border-error-tertiary: #{$tutor-orange-400}; + + // ACTION COLORS + --tutor-actions-success-primary: #{$tutor-cyan-500}; + --tutor-actions-success-secondary: #{$tutor-cyan-400}; + --tutor-actions-success-exception: #{$tutor-cyan-400}; + --tutor-actions-warning-primary: #{$tutor-orange-300}; + --tutor-actions-warning-tertiary: #{$tutor-orange-50}; + --tutor-actions-critical-primary: #{$tutor-orange-600}; + --tutor-actions-caution: #{$tutor-yellow-500}; + --tutor-actions-caution-secondary: #{$tutor-yellow-100}; + + // TEXT COLORS + --tutor-text-critical: #{$tutor-orange-600}; + --tutor-text-critical-hover: #{$tutor-orange-700}; + --tutor-text-warning: #{$tutor-orange-700}; + --tutor-text-caution: #{$tutor-yellow-700}; + + // VISUAL COLORS + --tutor-visual-success-1: #{$tutor-cyan-500}; + --tutor-visual-success-2: #{$tutor-cyan-500}; + --tutor-visual-exception-1: #{$tutor-cyan-200}; +} + +// ============================================================================= +// LIGHT — overrides specific to the light base theme +// ============================================================================= + +@mixin protanopia-light-tokens { + // SURFACE COLORS + --tutor-surface-success: #{$tutor-cyan-50}; + --tutor-surface-critical: #{$tutor-orange-100}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-cyan-700}; + + // ACTION COLORS + --tutor-actions-success-tertiary: #{$tutor-cyan-100}; + --tutor-actions-warning-secondary: #{$tutor-orange-100}; + --tutor-actions-warning-exception: #{$tutor-orange-600}; + --tutor-actions-critical-secondary: #{$tutor-orange-100}; + + // VISUAL COLORS + --tutor-visual-critical-1: #{$tutor-orange-500}; + --tutor-visual-critical-2: #{$tutor-orange-100}; +} + +// ============================================================================= +// DARK — overrides specific to the dark base theme +// ============================================================================= + +@mixin protanopia-dark-tokens { + // SURFACE COLORS + --tutor-surface-success: #{$tutor-cyan-900}; + --tutor-surface-critical: #{$tutor-orange-900}; + + // TEXT COLORS + --tutor-text-success: #{$tutor-cyan-400}; + + // ACTION COLORS + --tutor-actions-success-exception: #{$tutor-cyan-950}; + --tutor-actions-warning-exception: #{$tutor-orange-50}; + + // VISUAL COLORS + --tutor-visual-critical-1: #{$tutor-orange-600}; + --tutor-visual-critical-2: #{$tutor-orange-900}; +} + +[data-tutor-theme='light'][data-tutor-vision='protanopia'], +[data-tutor-theme='system'][data-tutor-vision='protanopia'] { + @include protanopia-light-tokens; +} + +[data-tutor-theme='dark'][data-tutor-vision='protanopia'] { + @include protanopia-dark-tokens; +} + +@media (prefers-color-scheme: dark) { + [data-tutor-theme='system'][data-tutor-vision='protanopia'] { + @include protanopia-dark-tokens; + } +} diff --git a/assets/core/scss/tokens/_actions.scss b/assets/core/scss/tokens/_actions.scss new file mode 100644 index 0000000000..072b014303 --- /dev/null +++ b/assets/core/scss/tokens/_actions.scss @@ -0,0 +1,58 @@ +// Design Tokens - Action Colors +// CSS variables for theme-aware action colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-actions-success-primary: var(--tutor-actions-success-primary); +$tutor-actions-success-secondary: var(--tutor-actions-success-secondary); +$tutor-actions-success-tertiary: var(--tutor-actions-success-tertiary); +$tutor-actions-success-exception: var(--tutor-actions-success-exception); +$tutor-actions-warning-primary: var(--tutor-actions-warning-primary); +$tutor-actions-warning-secondary: var(--tutor-actions-warning-secondary); +$tutor-actions-warning-tertiary: var(--tutor-actions-warning-tertiary); +$tutor-actions-warning-exception: var(--tutor-actions-warning-exception); +$tutor-actions-brand-primary: var(--tutor-actions-brand-primary); +$tutor-actions-brand-secondary: var(--tutor-actions-brand-secondary); +$tutor-actions-brand-tertiary: var(--tutor-actions-brand-tertiary); +$tutor-actions-gray-empty: var(--tutor-actions-gray-empty); +$tutor-actions-gray-secondary: var(--tutor-actions-gray-secondary); +$tutor-actions-gray-tertiary: var(--tutor-actions-gray-tertiary); +$tutor-actions-critical-primary: var(--tutor-actions-critical-primary); +$tutor-actions-critical-secondary: var(--tutor-actions-critical-secondary); +$tutor-actions-caution: var(--tutor-actions-caution); +$tutor-actions-caution-secondary: var(--tutor-actions-caution-secondary); +$tutor-actions-inverse: var(--tutor-actions-inverse); +$tutor-actions-exception3-highlight: var(--tutor-actions-exception3-highlight); +$tutor-actions-exception5: var(--tutor-actions-exception5); +$tutor-actions-exception6: var(--tutor-actions-exception6); + +// ============================================================================= +// ACTION COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-actions: ( + success-primary: $tutor-actions-success-primary, + success-secondary: $tutor-actions-success-secondary, + success-tertiary: $tutor-actions-success-tertiary, + success-exception: $tutor-actions-success-exception, + warning-primary: $tutor-actions-warning-primary, + warning-secondary: $tutor-actions-warning-secondary, + warning-tertiary: $tutor-actions-warning-tertiary, + warning-exception: $tutor-actions-warning-exception, + brand-primary: $tutor-actions-brand-primary, + brand-secondary: $tutor-actions-brand-secondary, + brand-tertiary: $tutor-actions-brand-tertiary, + gray-empty: $tutor-actions-gray-empty, + gray-secondary: $tutor-actions-gray-secondary, + gray-tertiary: $tutor-actions-gray-tertiary, + critical-primary: $tutor-actions-critical-primary, + critical-secondary: $tutor-actions-critical-secondary, + caution: $tutor-actions-caution, + caution-secondary: $tutor-actions-caution-secondary, + inverse: $tutor-actions-inverse, + exception3-highlight: $tutor-actions-exception3-highlight, + exception5: $tutor-actions-exception5, + exception6: $tutor-actions-exception6, +); diff --git a/assets/core/scss/tokens/_borders.scss b/assets/core/scss/tokens/_borders.scss new file mode 100644 index 0000000000..a405bf4a9f --- /dev/null +++ b/assets/core/scss/tokens/_borders.scss @@ -0,0 +1,84 @@ +// Design Tokens - Border +// Consistent border + +// ============================================================================= +// BORDER COLORS +// ============================================================================= + +$tutor-border-idle: var(--tutor-border-idle); +$tutor-border-idle2: var(--tutor-border-idle2); +$tutor-border-hover: var(--tutor-border-hover); +$tutor-border-inverse: var(--tutor-border-inverse); +$tutor-border-brand: var(--tutor-border-brand); +$tutor-border-brand-secondary: var(--tutor-border-brand-secondary); +$tutor-border-brand-tertiary: var(--tutor-border-brand-tertiary); +$tutor-border-dark: var(--tutor-border-dark); +$tutor-border-success: var(--tutor-border-success); +$tutor-border-success-secondary: var(--tutor-border-success-secondary); +$tutor-border-warning: var(--tutor-border-warning); +$tutor-border-warning-secondary: var(--tutor-border-warning-secondary); +$tutor-border-warning-tertiary: var(--tutor-border-warning-tertiary); +$tutor-border-error: var(--tutor-border-error); +$tutor-border-error-secondary: var(--tutor-border-error-secondary); +$tutor-border-error-tertiary: var(--tutor-border-error-tertiary); +$tutor-border-exception6: var(--tutor-border-exception6); +$tutor-border-tertiary: var(--tutor-border-tertiary); + +// ============================================================================= +// RADIUS SCALE +// ============================================================================= + +$tutor-radius-none: 0px; +$tutor-radius-xs: 2px; +$tutor-radius-sm: 4px; +$tutor-radius-md: 6px; +$tutor-radius-lg: 8px; +$tutor-radius-2xl: 12px; +$tutor-radius-3xl: 16px; +$tutor-radius-4xl: 20px; +$tutor-radius-5xl: 24px; +$tutor-radius-6xl: 32px; +$tutor-radius-full: 1000px; + +// ============================================================================= +// BORDER COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-border-colors: ( + idle: $tutor-border-idle, + idle2: $tutor-border-idle2, + hover: $tutor-border-hover, + inverse: $tutor-border-inverse, + brand: $tutor-border-brand, + brand-secondary: $tutor-border-brand-secondary, + brand-tertiary: $tutor-border-brand-tertiary, + dark: $tutor-border-dark, + success: $tutor-border-success, + success-secondary: $tutor-border-success-secondary, + warning: $tutor-border-warning, + warning-secondary: $tutor-border-warning-secondary, + warning-tertiary: $tutor-border-warning-tertiary, + error: $tutor-border-error, + error-secondary: $tutor-border-error-secondary, + error-tertiary: $tutor-border-error-tertiary, + exception6: $tutor-border-exception6, + tertiary: $tutor-border-tertiary, +); + +// ============================================================================= +// RADIUS MAP (for utility generation) +// ============================================================================= + +$tutor-radius: ( + none: $tutor-radius-none, + xs: $tutor-radius-xs, + sm: $tutor-radius-sm, + md: $tutor-radius-md, + lg: $tutor-radius-lg, + 2xl: $tutor-radius-2xl, + 3xl: $tutor-radius-3xl, + 4xl: $tutor-radius-4xl, + 5xl: $tutor-radius-5xl, + 6xl: $tutor-radius-6xl, + full: $tutor-radius-full, +); diff --git a/assets/core/scss/tokens/_breakpoints.scss b/assets/core/scss/tokens/_breakpoints.scss new file mode 100644 index 0000000000..a0acf07488 --- /dev/null +++ b/assets/core/scss/tokens/_breakpoints.scss @@ -0,0 +1,21 @@ +// Design Tokens - Breakpoints +// Mobile-first responsive design breakpoints + +$tutor-breakpoint-xs: 480px; +$tutor-breakpoint-sm: 576px; +$tutor-breakpoint-md: 768px; +$tutor-breakpoint-lg: 992px; +$tutor-breakpoint-xl: 1200px; +$tutor-breakpoint-2xl: 1400px; +$tutor-breakpoint-3xl: 1920px; + +// Breakpoint map for mixins +$tutor-breakpoints: ( + 3xl: $tutor-breakpoint-3xl, + 2xl: $tutor-breakpoint-2xl, + xl: $tutor-breakpoint-xl, + lg: $tutor-breakpoint-lg, + md: $tutor-breakpoint-md, + sm: $tutor-breakpoint-sm, + xs: $tutor-breakpoint-xs, +); diff --git a/assets/core/scss/tokens/_buttons.scss b/assets/core/scss/tokens/_buttons.scss new file mode 100644 index 0000000000..c673779110 --- /dev/null +++ b/assets/core/scss/tokens/_buttons.scss @@ -0,0 +1,183 @@ +@use './borders' as *; +@use './typography' as *; + +// Design Tokens - Button Colors +// CSS variables for theme-aware button colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +// --- Base --- +$tutor-button-radius: var(--tutor-button-radius, $tutor-radius-md); +$tutor-button-font-weight: var(--tutor-button-font-weight, $tutor-font-weight-medium); +$tutor-button-border-shadow: var(--tutor-button-border-shadow, none); + +// --- Primary --- +$tutor-button-primary: var(--tutor-button-primary); +$tutor-button-primary-hover: var(--tutor-button-primary-hover); +$tutor-button-primary-focused: var(--tutor-button-primary-focused); +$tutor-button-primary-disabled: var(--tutor-button-primary-disabled); +$tutor-button-primary-border-shadow: var(--tutor-button-primary-border-shadow, none); +$tutor-button-primary-border-shadow-hover: var( + --tutor-button-primary-border-shadow-hover, + var(--tutor-button-primary-border-shadow, none) +); +$tutor-button-primary-border-shadow-focused: var( + --tutor-button-primary-border-shadow-focused, + var(--tutor-button-primary-border-shadow, none) +); +$tutor-button-primary-border-shadow-disabled: var( + --tutor-button-primary-border-shadow-disabled, + var(--tutor-button-primary-border-shadow, none) +); + +// --- Primary Soft --- +$tutor-button-primary-soft: var(--tutor-button-primary-soft); +$tutor-button-primary-soft-hover: var(--tutor-button-primary-soft-hover); +$tutor-button-primary-soft-focused: var(--tutor-button-primary-soft-focused); +$tutor-button-primary-soft-border-shadow: var(--tutor-button-primary-soft-border-shadow, none); +$tutor-button-primary-soft-border-shadow-hover: var( + --tutor-button-primary-soft-border-shadow-hover, + var(--tutor-button-primary-soft-border-shadow, none) +); +$tutor-button-primary-soft-border-shadow-focused: var( + --tutor-button-primary-soft-border-shadow-focused, + var(--tutor-button-primary-soft-border-shadow, none) +); +$tutor-button-primary-soft-border-shadow-disabled: var( + --tutor-button-primary-soft-border-shadow-disabled, + var(--tutor-button-primary-soft-border-shadow, none) +); + +// --- Destructive --- +$tutor-button-destructive: var(--tutor-button-destructive); +$tutor-button-destructive-hover: var(--tutor-button-destructive-hover); +$tutor-button-destructive-focused: var(--tutor-button-destructive-focused); +$tutor-button-destructive-border-shadow: var(--tutor-button-destructive-border-shadow, none); +$tutor-button-destructive-border-shadow-hover: var( + --tutor-button-destructive-border-shadow-hover, + var(--tutor-button-destructive-border-shadow, none) +); +$tutor-button-destructive-border-shadow-focused: var( + --tutor-button-destructive-border-shadow-focused, + var(--tutor-button-destructive-border-shadow, none) +); +$tutor-button-destructive-border-shadow-disabled: var( + --tutor-button-destructive-border-shadow-disabled, + var(--tutor-button-destructive-border-shadow, none) +); + +// --- Destructive Soft --- +$tutor-button-destructive-soft: var(--tutor-button-destructive-soft); +$tutor-button-destructive-soft-hover: var(--tutor-button-destructive-soft-hover); +$tutor-button-destructive-soft-focused: var(--tutor-button-destructive-soft-focused); +$tutor-button-destructive-soft-border-shadow: var(--tutor-button-destructive-soft-border-shadow, none); +$tutor-button-destructive-soft-border-shadow-hover: var( + --tutor-button-destructive-soft-border-shadow-hover, + var(--tutor-button-destructive-soft-border-shadow, none) +); +$tutor-button-destructive-soft-border-shadow-focused: var( + --tutor-button-destructive-soft-border-shadow-focused, + var(--tutor-button-destructive-soft-border-shadow, none) +); +$tutor-button-destructive-soft-border-shadow-disabled: var( + --tutor-button-destructive-soft-border-shadow-disabled, + var(--tutor-button-destructive-soft-border-shadow, none) +); + +// --- Secondary --- +$tutor-button-secondary: var(--tutor-button-secondary); +$tutor-button-secondary-hover: var(--tutor-button-secondary-hover); +$tutor-button-secondary-focused: var(--tutor-button-secondary-focused); +$tutor-button-secondary-border-shadow: var(--tutor-button-secondary-border-shadow, none); +$tutor-button-secondary-border-shadow-hover: var( + --tutor-button-secondary-border-shadow-hover, + var(--tutor-button-secondary-border-shadow, none) +); +$tutor-button-secondary-border-shadow-focused: var( + --tutor-button-secondary-border-shadow-focused, + var(--tutor-button-secondary-border-shadow, none) +); +$tutor-button-secondary-border-shadow-disabled: var( + --tutor-button-secondary-border-shadow-disabled, + var(--tutor-button-secondary-border-shadow, none) +); + +// --- Outline --- +$tutor-button-outline-inverse: var(--tutor-button-outline-inverse); +$tutor-button-outline-hover: var(--tutor-button-outline-hover); +$tutor-button-outline-focused-inverse: var(--tutor-button-outline-focused-inverse); +$tutor-button-outline-border-shadow: var(--tutor-button-outline-border-shadow, 0 0 0 1px #{$tutor-border-idle2}); +$tutor-button-outline-border-shadow-hover: var( + --tutor-button-outline-border-shadow-hover, + #{$tutor-button-outline-border-shadow} +); +$tutor-button-outline-border-shadow-focused: var( + --tutor-button-outline-border-shadow-focused, + #{$tutor-button-outline-border-shadow} +); +$tutor-button-outline-border-shadow-disabled: var( + --tutor-button-outline-border-shadow-disabled, + #{$tutor-button-outline-border-shadow} +); + +// --- Ghost --- +$tutor-button-ghost-hover: var(--tutor-button-ghost-hover); +$tutor-button-ghost-border-shadow: var(--tutor-button-ghost-border-shadow, none); +$tutor-button-ghost-border-shadow-hover: var( + --tutor-button-ghost-border-shadow-hover, + var(--tutor-button-ghost-border-shadow, none) +); +$tutor-button-ghost-border-shadow-disabled: var( + --tutor-button-ghost-border-shadow-disabled, + var(--tutor-button-ghost-border-shadow, none) +); + +// --- Ghost Brand --- +$tutor-button-ghost-brand-border-shadow: var(--tutor-button-ghost-brand-border-shadow, none); +$tutor-button-ghost-brand-border-shadow-hover: var( + --tutor-button-ghost-brand-border-shadow-hover, + var(--tutor-button-ghost-brand-border-shadow, none) +); +$tutor-button-ghost-brand-border-shadow-disabled: var( + --tutor-button-ghost-brand-border-shadow-disabled, + var(--tutor-button-ghost-brand-border-shadow, none) +); + +// --- Shared --- +$tutor-button-disabled: var(--tutor-button-disabled); +$tutor-button-success: var(--tutor-button-success); +$tutor-button-success-hover: var(--tutor-button-success-hover); +$tutor-button-success-focused: var(--tutor-button-success-focused); + +// ============================================================================= +// BUTTON COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-buttons: ( + primary: $tutor-button-primary, + primary-hover: $tutor-button-primary-hover, + primary-focused: $tutor-button-primary-focused, + primary-disabled: $tutor-button-primary-disabled, + primary-soft: $tutor-button-primary-soft, + primary-soft-hover: $tutor-button-primary-soft-hover, + primary-soft-focused: $tutor-button-primary-soft-focused, + secondary: $tutor-button-secondary, + secondary-hover: $tutor-button-secondary-hover, + secondary-focused: $tutor-button-secondary-focused, + destructive: $tutor-button-destructive, + destructive-hover: $tutor-button-destructive-hover, + destructive-focused: $tutor-button-destructive-focused, + destructive-soft: $tutor-button-destructive-soft, + destructive-soft-hover: $tutor-button-destructive-soft-hover, + destructive-soft-focused: $tutor-button-destructive-soft-focused, + disabled: $tutor-button-disabled, + outline-inverse: $tutor-button-outline-inverse, + outline-hover: $tutor-button-outline-hover, + outline-focused-inverse: $tutor-button-outline-focused-inverse, + ghost-hover: $tutor-button-ghost-hover, + success: $tutor-button-success, + success-hover: $tutor-button-success-hover, + success-focused: $tutor-button-success-focused, +); diff --git a/assets/core/scss/tokens/_colors.scss b/assets/core/scss/tokens/_colors.scss new file mode 100644 index 0000000000..b0bec1be0c --- /dev/null +++ b/assets/core/scss/tokens/_colors.scss @@ -0,0 +1,260 @@ +// Design Tokens - Colors +// Primitive color palette and semantic color maps + +// ============================================================================= +// PRIMITIVE COLORS +// ============================================================================= + +// Brand Colors +$tutor-brand-100: var(--tutor-brand-100, #f6f8fe); +$tutor-brand-200: var(--tutor-brand-200, #e4ebfc); +$tutor-brand-300: var(--tutor-brand-300, #dbe4fa); +$tutor-brand-400: var(--tutor-brand-400, #a4bcf4); +$tutor-brand-500: var(--tutor-brand-500, #4979e8); +$tutor-brand-600: var(--tutor-brand-600, #3e64de); +$tutor-brand-700: var(--tutor-brand-700, #2b49ca); +$tutor-brand-800: var(--tutor-brand-800, #293da4); +$tutor-brand-900: var(--tutor-brand-900, #263782); +$tutor-brand-950: var(--tutor-brand-950, #1c234f); + +// Gray Colors +$tutor-gray-1: #ffffff; +$tutor-gray-10: #fcfcfc; +$tutor-gray-25: #fafafa; +$tutor-gray-50: #f5f5f6; +$tutor-gray-100: #f0f1f1; +$tutor-gray-200: #ececed; +$tutor-gray-300: #cecfd2; +$tutor-gray-400: #94969c; +$tutor-gray-500: #73767D; +$tutor-gray-600: #61646c; +$tutor-gray-700: #333741; +$tutor-gray-750: #2d3039; +$tutor-gray-800: #1f242f; +$tutor-gray-900: #161b26; +$tutor-gray-950: #0c111d; + +// Success Colors +$tutor-success-25: #f4faf5; +$tutor-success-50: #eaf6ec; +$tutor-success-100: #bce4c5; +$tutor-success-200: #9cd7a9; +$tutor-success-300: #6fc482; +$tutor-success-400: #53b96a; +$tutor-success-500: #28a745; +$tutor-success-600: #24983f; +$tutor-success-700: #1c7731; +$tutor-success-800: #165c26; +$tutor-success-900: #11461d; +$tutor-success-950: #0c3114; + +// Warning Colors +$tutor-warning-25: #fffcf5; +$tutor-warning-50: #fffaeb; +$tutor-warning-100: #fef0c7; +$tutor-warning-200: #fedf89; +$tutor-warning-300: #fec84b; +$tutor-warning-400: #fdb022; +$tutor-warning-500: #f79009; +$tutor-warning-600: #dc6803; +$tutor-warning-700: #b54708; +$tutor-warning-800: #93370d; +$tutor-warning-900: #7a2e0e; +$tutor-warning-950: #4e1d09; + +// Error Colors +$tutor-error-25: #fffbfa; +$tutor-error-50: #fef3f2; +$tutor-error-100: #fee4e2; +$tutor-error-200: #fecdca; +$tutor-error-300: #fda29b; +$tutor-error-400: #f97066; +$tutor-error-500: #f04438; +$tutor-error-600: #d92d20; +$tutor-error-700: #b42318; +$tutor-error-800: #912018; +$tutor-error-900: #7a271a; +$tutor-error-950: #55160c; + +// Yellow Colors +$tutor-yellow-25: #fefdf0; +$tutor-yellow-50: #fefbe8; +$tutor-yellow-100: #fef7c3; +$tutor-yellow-200: #feee95; +$tutor-yellow-300: #fde272; +$tutor-yellow-400: #fac515; +$tutor-yellow-500: #eaaa08; +$tutor-yellow-600: #ca8504; +$tutor-yellow-700: #a15c07; +$tutor-yellow-800: #854a0e; +$tutor-yellow-900: #713b12; +$tutor-yellow-950: #542c0d; + +// Cyan Colors (CVD accessibility - replaces green/success) +$tutor-cyan-50: #ecfeff; +$tutor-cyan-100: #cefafe; +$tutor-cyan-200: #a2f4fd; +$tutor-cyan-300: #53eafd; +$tutor-cyan-400: #00d3f2; +$tutor-cyan-500: #00b8db; +$tutor-cyan-600: #0092b8; +$tutor-cyan-700: #007595; +$tutor-cyan-800: #005f78; +$tutor-cyan-900: #104e64; +$tutor-cyan-950: #053345; + +// Orange Colors (CVD accessibility - replaces red/error) +$tutor-orange-50: #fefbe8; +$tutor-orange-100: #ffedd4; +$tutor-orange-200: #ffd6a7; +$tutor-orange-300: #ffb86a; +$tutor-orange-400: #ff8904; +$tutor-orange-500: #ff6900; +$tutor-orange-600: #f54900; +$tutor-orange-700: #ca3500; +$tutor-orange-800: #9f2d00; +$tutor-orange-900: #7e2a0c; + +// Exception Colors +$tutor-exception-1: #00acc2; +$tutor-exception-2: #ee0097; +$tutor-exception-2-secondary: #fff0f9; +$tutor-exception-2-tertiary: #330020; +$tutor-exception-3: #cbfd78; +$tutor-exception-5: #9747ff; +$tutor-exception-6: #f2e8ff; +$tutor-exception-7: #242a37; +$tutor-exception-8: #2c0066; +$tutor-exception-9: #f4f433; + +// ============================================================================= +// COLOR MAPS (for utility generation) +// ============================================================================= + +$tutor-brand-colors: ( + 100: $tutor-brand-100, + 200: $tutor-brand-200, + 300: $tutor-brand-300, + 400: $tutor-brand-400, + 500: $tutor-brand-500, + 600: $tutor-brand-600, + 700: $tutor-brand-700, + 800: $tutor-brand-800, + 900: $tutor-brand-900, + 950: $tutor-brand-950, +); + +$tutor-gray-colors: ( + 1: $tutor-gray-1, + 10: $tutor-gray-10, + 25: $tutor-gray-25, + 50: $tutor-gray-50, + 100: $tutor-gray-100, + 200: $tutor-gray-200, + 300: $tutor-gray-300, + 400: $tutor-gray-400, + 500: $tutor-gray-500, + 600: $tutor-gray-600, + 700: $tutor-gray-700, + 750: $tutor-gray-750, + 800: $tutor-gray-800, + 900: $tutor-gray-900, + 950: $tutor-gray-950, +); + +$tutor-success-colors: ( + 25: $tutor-success-25, + 50: $tutor-success-50, + 100: $tutor-success-100, + 200: $tutor-success-200, + 300: $tutor-success-300, + 400: $tutor-success-400, + 500: $tutor-success-500, + 600: $tutor-success-600, + 700: $tutor-success-700, + 800: $tutor-success-800, + 900: $tutor-success-900, + 950: $tutor-success-950, +); + +$tutor-warning-colors: ( + 25: $tutor-warning-25, + 50: $tutor-warning-50, + 100: $tutor-warning-100, + 200: $tutor-warning-200, + 300: $tutor-warning-300, + 400: $tutor-warning-400, + 500: $tutor-warning-500, + 600: $tutor-warning-600, + 700: $tutor-warning-700, + 800: $tutor-warning-800, + 900: $tutor-warning-900, + 950: $tutor-warning-950, +); + +$tutor-error-colors: ( + 25: $tutor-error-25, + 50: $tutor-error-50, + 100: $tutor-error-100, + 200: $tutor-error-200, + 300: $tutor-error-300, + 400: $tutor-error-400, + 500: $tutor-error-500, + 600: $tutor-error-600, + 700: $tutor-error-700, + 800: $tutor-error-800, + 900: $tutor-error-900, + 950: $tutor-error-950, +); + +$tutor-yellow-colors: ( + 25: $tutor-yellow-25, + 50: $tutor-yellow-50, + 100: $tutor-yellow-100, + 200: $tutor-yellow-200, + 300: $tutor-yellow-300, + 400: $tutor-yellow-400, + 500: $tutor-yellow-500, + 600: $tutor-yellow-600, + 700: $tutor-yellow-700, + 800: $tutor-yellow-800, + 900: $tutor-yellow-900, + 950: $tutor-yellow-950, +); + +$tutor-exception-colors: ( + 1: $tutor-exception-1, + 2: $tutor-exception-2, + 2-secondary: $tutor-exception-2-secondary, + 2-tertiary: $tutor-exception-2-tertiary, + 3: $tutor-exception-3, + 5: $tutor-exception-5, + 6: $tutor-exception-6, +); + +$tutor-cyan-colors: ( + 50: $tutor-cyan-50, + 100: $tutor-cyan-100, + 200: $tutor-cyan-200, + 300: $tutor-cyan-300, + 400: $tutor-cyan-400, + 500: $tutor-cyan-500, + 600: $tutor-cyan-600, + 700: $tutor-cyan-700, + 800: $tutor-cyan-800, + 900: $tutor-cyan-900, + 950: $tutor-cyan-950, +); + +$tutor-orange-colors: ( + 50: $tutor-orange-50, + 100: $tutor-orange-100, + 200: $tutor-orange-200, + 300: $tutor-orange-300, + 400: $tutor-orange-400, + 500: $tutor-orange-500, + 600: $tutor-orange-600, + 700: $tutor-orange-700, + 800: $tutor-orange-800, + 900: $tutor-orange-900, +); diff --git a/assets/core/scss/tokens/_file-uploader.scss b/assets/core/scss/tokens/_file-uploader.scss new file mode 100644 index 0000000000..8566f6044d --- /dev/null +++ b/assets/core/scss/tokens/_file-uploader.scss @@ -0,0 +1,10 @@ +@use './colors' as *; + +$tutor-file-uploader-background-image: var( + --tutor-file-uploader-background-image, + url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='6' ry='6' stroke='%23A4BCF4FF' stroke-width='3' stroke-dasharray='6%2c8' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e") +); + +$tutor-file-uploader: ( + background-image: $tutor-file-uploader-background-image, +); diff --git a/assets/core/scss/tokens/_icons.scss b/assets/core/scss/tokens/_icons.scss new file mode 100644 index 0000000000..45967dad93 --- /dev/null +++ b/assets/core/scss/tokens/_icons.scss @@ -0,0 +1,54 @@ +// Design Tokens - Icon Colors +// CSS variables for theme-aware icon colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-icon-idle: var(--tutor-icon-idle); +$tutor-icon-idle-inverse: var(--tutor-icon-idle-inverse); +$tutor-icon-hover: var(--tutor-icon-hover); +$tutor-icon-secondary: var(--tutor-icon-secondary); +$tutor-icon-subdued: var(--tutor-icon-subdued); +$tutor-icon-brand: var(--tutor-icon-brand); +$tutor-icon-brand-hover: var(--tutor-icon-brand-hover); +$tutor-icon-brand-secondary: var(--tutor-icon-brand-secondary); +$tutor-icon-success-primary: var(--tutor-icon-success-primary); +$tutor-icon-success-secondary: var(--tutor-icon-success-secondary); +$tutor-icon-critical: var(--tutor-icon-critical); +$tutor-icon-critical-hover: var(--tutor-icon-critical-hover); +$tutor-icon-warning: var(--tutor-icon-warning); +$tutor-icon-warning-secondary: var(--tutor-icon-warning-secondary); +$tutor-icon-caution: var(--tutor-icon-caution); +$tutor-icon-exception1: var(--tutor-icon-exception1); +$tutor-icon-exception2: var(--tutor-icon-exception2); +$tutor-icon-exception4: var(--tutor-icon-exception4); +$tutor-icon-exception5: var(--tutor-icon-exception5); +$tutor-icon-disabled: var(--tutor-icon-disabled); + +// ============================================================================= +// ICON COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-icons: ( + idle: $tutor-icon-idle, + idle-inverse: $tutor-icon-idle-inverse, + hover: $tutor-icon-hover, + secondary: $tutor-icon-secondary, + subdued: $tutor-icon-subdued, + brand: $tutor-icon-brand, + brand-hover: $tutor-icon-brand-hover, + brand-secondary: $tutor-icon-brand-secondary, + success-primary: $tutor-icon-success-primary, + success-secondary: $tutor-icon-success-secondary, + critical: $tutor-icon-critical, + critical-hover: $tutor-icon-critical-hover, + warning: $tutor-icon-warning, + warning-secondary: $tutor-icon-warning-secondary, + caution: $tutor-icon-caution, + exception1: $tutor-icon-exception1, + exception2: $tutor-icon-exception2, + exception4: $tutor-icon-exception4, + exception5: $tutor-icon-exception5, + disabled: $tutor-icon-disabled, +); diff --git a/assets/core/scss/tokens/_index.scss b/assets/core/scss/tokens/_index.scss new file mode 100644 index 0000000000..a075590d89 --- /dev/null +++ b/assets/core/scss/tokens/_index.scss @@ -0,0 +1,22 @@ +@forward 'utility-config'; +@forward 'actions'; +@forward 'buttons'; +@forward 'borders'; +@forward 'breakpoints'; +@forward 'colors'; +@forward 'file-uploader'; +@forward 'icons'; +@forward 'inputs'; +@forward 'shadows'; +@forward 'spacing'; +@forward 'surfaces'; +@forward 'tabs'; +@forward 'text-colors'; +@forward 'typography'; +@forward 'zIndex'; +@forward 'popover'; +@forward 'modal'; +@forward 'progress'; +@forward 'quiz'; +@forward 'visual'; +@forward 'toast'; diff --git a/assets/core/scss/tokens/_inputs.scss b/assets/core/scss/tokens/_inputs.scss new file mode 100644 index 0000000000..3b24dce6fc --- /dev/null +++ b/assets/core/scss/tokens/_inputs.scss @@ -0,0 +1,10 @@ +@use './borders' as *; +@use './typography' as *; + +// Design Tokens - Input +// CSS variables for theme-aware input styling + +$tutor-input-radius: var(--tutor-input-radius, $tutor-radius-md); +$tutor-input-font-weight: var(--tutor-input-font-weight, $tutor-font-weight-regular); +$tutor-input-border-color: var(--tutor-input-border-color, $tutor-border-idle); +$tutor-input-border-shadow: var(--tutor-input-border-shadow, none); diff --git a/assets/core/scss/tokens/_modal.scss b/assets/core/scss/tokens/_modal.scss new file mode 100644 index 0000000000..bea56ee5da --- /dev/null +++ b/assets/core/scss/tokens/_modal.scss @@ -0,0 +1,10 @@ +@use './borders' as *; +@use './shadows' as *; + +$tutor-modal-shadow: var(--tutor-modal-shadow, #{$tutor-shadow-modal}); +$tutor-modal-radius: var(--tutor-modal-radius, #{$tutor-radius-3xl}); + +$tutor-modal: ( + shadow: $tutor-modal-shadow, + radius: $tutor-modal-radius, +); diff --git a/assets/core/scss/tokens/_popover.scss b/assets/core/scss/tokens/_popover.scss new file mode 100644 index 0000000000..db16201f26 --- /dev/null +++ b/assets/core/scss/tokens/_popover.scss @@ -0,0 +1,12 @@ +@use './borders' as *; +@use './shadows' as *; + +$tutor-popover-shadow: var(--tutor-popover-shadow, #{$tutor-shadow-lg}); +$tutor-popover-border: var(--tutor-popover-border, 1px solid #{$tutor-border-idle}); +$tutor-popover-radius: var(--tutor-popover-radius, #{$tutor-radius-2xl}); + +$tutor-popover: ( + shadow: $tutor-popover-shadow, + border: $tutor-popover-border, + radius: $tutor-popover-radius, +) \ No newline at end of file diff --git a/assets/core/scss/tokens/_progress.scss b/assets/core/scss/tokens/_progress.scss new file mode 100644 index 0000000000..f443bd11af --- /dev/null +++ b/assets/core/scss/tokens/_progress.scss @@ -0,0 +1,9 @@ +@use './spacing'; + +// Design Tokens - Progress +// CSS variables for theme-aware progress geometry + +$tutor-progress-height: var(--tutor-progress-height, 8px); +$tutor-progress-indicator-top: var(--tutor-progress-indicator-top, 2px); +$tutor-progress-indicator-height: var(--tutor-progress-indicator-height, 1.5px); +$tutor-progress-indicator-secondary-display: var(--tutor-progress-indicator-secondary-display, none); diff --git a/assets/core/scss/tokens/_quiz.scss b/assets/core/scss/tokens/_quiz.scss new file mode 100644 index 0000000000..e12f91b6d5 --- /dev/null +++ b/assets/core/scss/tokens/_quiz.scss @@ -0,0 +1,18 @@ +// Design Tokens - Quiz Colors +// CSS variables for theme-aware quiz colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-quiz-idle: var(--tutor-quiz-idle); +$tutor-quiz-hover: var(--tutor-quiz-hover); + +// ============================================================================= +// QUIZ COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-quiz-colors: ( + idle: $tutor-quiz-idle, + hover: $tutor-quiz-hover, +); diff --git a/assets/core/scss/tokens/_shadows.scss b/assets/core/scss/tokens/_shadows.scss new file mode 100644 index 0000000000..6ab3dbe795 --- /dev/null +++ b/assets/core/scss/tokens/_shadows.scss @@ -0,0 +1,52 @@ +// ========================================================================== +// Shadows +// ========================================================================== + +$tutor-shadow-modal: 0px 8px 8px -4px #10182808, 0px 20px 24px -4px #10182814; +$tutor-shadow-xs: 0px 1px 2px 0px #1018280D; +$tutor-shadow-sm: 0px 1px 2px 0px #1018280F, 0px 1px 3px 0px #1018281A; +$tutor-shadow-md: 0px 2px 4px -2px #1018280F, 0px 4px 8px -2px #1018281A; +$tutor-shadow-lg: 0px 4px 6px -2px #10182808, 0px 12px 16px -4px #10182814; +$tutor-shadow-xl: 0px 8px 8px -4px #10182808, 0px 20px 24px -4px #10182814; +$tutor-shadow-2xl: 0px 24px 48px -12px #1018282E; +$tutor-shadow-3xl: 0px 32px 64px -12px #10182824; + +// ========================================================================== +// Ring Shadows +// ========================================================================== + +$tutor-ring-brand-shadow-xs: 0px 0px 0px 2px #90A0F7; +$tutor-ring-brand-shadow-sm: 0px 0px 0px 4px #3E64DE3D, 0px 1px 2px 0px #1018280F, 0px 1px 3px 0px #1018281A; +$tutor-ring-gray-shadow-md: 0px 0px 0px 4px #98A2B324, 0px 1px 2px 0px #1018280D; +$tutor-ring-gray-shadow-sm: 0px 0px 0px 2px #98A2B324, 0px 1px 2px 0px #1018280F, 0px 1px 3px 0px #1018281A; +$tutor-error-shadow-xs: 0px 0px 0px 2px #F044383D, 0px 1px 2px 0px #1018280D; + +// ========================================================================== +// Shadow Tokens +// ========================================================================== + + +$tutor-shadows: ( + 'none': none, + 'modal': $tutor-shadow-modal, + 'xs': $tutor-shadow-xs, + 'sm': $tutor-shadow-sm, + 'md': $tutor-shadow-md, + 'lg': $tutor-shadow-lg, + 'xl': $tutor-shadow-xl, + '2xl': $tutor-shadow-2xl, + '3xl': $tutor-shadow-3xl, +); + +// ========================================================================== +// Ring Shadow Tokens +// ========================================================================== + + +$tutor-ring-shadows: ( + 'brand-xs': $tutor-ring-brand-shadow-xs, + 'brand-sm': $tutor-ring-brand-shadow-sm, + 'gray-md': $tutor-ring-gray-shadow-md, + 'gray-sm': $tutor-ring-gray-shadow-sm, + 'error-xs': $tutor-error-shadow-xs, +); \ No newline at end of file diff --git a/assets/core/scss/tokens/_spacing.scss b/assets/core/scss/tokens/_spacing.scss new file mode 100644 index 0000000000..e3b0a8e3bf --- /dev/null +++ b/assets/core/scss/tokens/_spacing.scss @@ -0,0 +1,58 @@ +// Design Tokens - Spacing +// Consistent spacing scale for margins, padding, and gaps + +// ============================================================================= +// SPACING SCALE +// ============================================================================= + +$tutor-spacing-none: 0px; +$tutor-spacing-1: 2px; +$tutor-spacing-2: 4px; +$tutor-spacing-3: 6px; +$tutor-spacing-4: 8px; +$tutor-spacing-5: 12px; +$tutor-spacing-6: 16px; +$tutor-spacing-7: 20px; +$tutor-spacing-8: 24px; +$tutor-spacing-9: 32px; +$tutor-spacing-10: 40px; +$tutor-spacing-11: 48px; +$tutor-spacing-12: 56px; +$tutor-spacing-13: 64px; +$tutor-spacing-14: 72px; +$tutor-spacing-15: 80px; +$tutor-spacing-16: 88px; +$tutor-spacing-17: 96px; +$tutor-spacing-18: 104px; +$tutor-spacing-19: 112px; +$tutor-spacing-20: 120px; +$tutor-spacing-21: 200px; + +// ============================================================================= +// SPACING MAP (for utility generation) +// ============================================================================= + +$tutor-spacing: ( + none: $tutor-spacing-none, + 1: $tutor-spacing-1, + 2: $tutor-spacing-2, + 3: $tutor-spacing-3, + 4: $tutor-spacing-4, + 5: $tutor-spacing-5, + 6: $tutor-spacing-6, + 7: $tutor-spacing-7, + 8: $tutor-spacing-8, + 9: $tutor-spacing-9, + 10: $tutor-spacing-10, + 11: $tutor-spacing-11, + 12: $tutor-spacing-12, + 13: $tutor-spacing-13, + 14: $tutor-spacing-14, + 15: $tutor-spacing-15, + 16: $tutor-spacing-16, + 17: $tutor-spacing-17, + 18: $tutor-spacing-18, + 19: $tutor-spacing-19, + 20: $tutor-spacing-20, + 21: $tutor-spacing-21 +); \ No newline at end of file diff --git a/assets/core/scss/tokens/_surfaces.scss b/assets/core/scss/tokens/_surfaces.scss new file mode 100644 index 0000000000..afdbb1aa76 --- /dev/null +++ b/assets/core/scss/tokens/_surfaces.scss @@ -0,0 +1,58 @@ +// Design Tokens - Surface Colors +// CSS variables for theme-aware surface colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-surface-base: var(--tutor-surface-base); +$tutor-surface-l1: var(--tutor-surface-l1); +$tutor-surface-l1-hover: var(--tutor-surface-l1-hover); +$tutor-surface-l2: var(--tutor-surface-l2); +$tutor-surface-l2-hover: var(--tutor-surface-l2-hover); +$tutor-surface-l3: var(--tutor-surface-l3); +$tutor-surface-brand-dark: var(--tutor-surface-brand-dark); +$tutor-surface-brand-primary: var(--tutor-surface-brand-primary); +$tutor-surface-brand-primary-2: var(--tutor-surface-brand-primary-2); +$tutor-surface-brand-secondary: var(--tutor-surface-brand-secondary); +$tutor-surface-brand-tertiary: var(--tutor-surface-brand-tertiary); +$tutor-surface-brand-quaternary: var(--tutor-surface-brand-quaternary); +$tutor-surface-sidebar-l1: var(--tutor-surface-sidebar-l1); +$tutor-surface-exception2-secondary: var(--tutor-surface-exception2-secondary); +$tutor-surface-exception3-highlight: var(--tutor-surface-exception3-highlight); +$tutor-surface-dark: var(--tutor-surface-dark); +$tutor-surface-exception6: var(--tutor-surface-exception6); +$tutor-surface-exception7: var(--tutor-surface-exception7); +$tutor-surface-warning: var(--tutor-surface-warning); +$tutor-surface-warning-hover: var(--tutor-surface-warning-hover); +$tutor-surface-success: var(--tutor-surface-success); +$tutor-surface-critical: var(--tutor-surface-critical); + +// ============================================================================= +// SURFACE COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-surfaces: ( + base: $tutor-surface-base, + l1: $tutor-surface-l1, + l1-hover: $tutor-surface-l1-hover, + l2: $tutor-surface-l2, + l2-hover: $tutor-surface-l2-hover, + l3: $tutor-surface-l3, + brand-dark: $tutor-surface-brand-dark, + brand-primary: $tutor-surface-brand-primary, + brand-primary-2: $tutor-surface-brand-primary-2, + brand-secondary: $tutor-surface-brand-secondary, + brand-tertiary: $tutor-surface-brand-tertiary, + brand-quaternary: $tutor-surface-brand-quaternary, + sidebar-l1: $tutor-surface-sidebar-l1, + exception2-secondary: $tutor-surface-exception2-secondary, + exception3-highlight: $tutor-surface-exception3-highlight, + dark: $tutor-surface-dark, + exception6: $tutor-surface-exception6, + exception7: $tutor-surface-exception7, + warning: $tutor-surface-warning, + warning-hover: $tutor-surface-warning-hover, + success: $tutor-surface-success, + critical: $tutor-surface-critical, +); diff --git a/assets/core/scss/tokens/_tabs.scss b/assets/core/scss/tokens/_tabs.scss new file mode 100644 index 0000000000..889bbee25a --- /dev/null +++ b/assets/core/scss/tokens/_tabs.scss @@ -0,0 +1,42 @@ +@use '../tokens/borders' as *; +@use '../tokens/typography' as *; + +// Design Tokens - Tab Colors +// CSS variables for theme-aware tab colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= +$tutor-tab-radius: var(--tutor-tab-radius, $tutor-radius-lg); +$tutor-tab-radius-sm: var(--tutor-tab-radius-sm, $tutor-radius-md); +$tutor-tab-font-weight: var(--tutor-tab-font-weight, $tutor-font-weight-medium); +$tutor-tab-active-shadow: var(--tutor-tab-active-shadow, none); +$tutor-tab-height: var(--tutor-tab-height, 40px); +$tutor-tab-height-sm: var(--tutor-tab-height-sm, 34px); +$tutor-tab-height-lg: var(--tutor-tab-height-lg, 44px); + +$tutor-tab-sidebar-l2: var(--tutor-tab-sidebar-l2); +$tutor-tab-sidebar-l2-hover: var(--tutor-tab-sidebar-l2-hover); +$tutor-tab-sidebar-l2-active: var(--tutor-tab-sidebar-l2-active); +$tutor-tab-sidebar-l4-hover: var(--tutor-tab-sidebar-l4-hover); +$tutor-tab-sidebar-l4-active: var(--tutor-tab-sidebar-l4-active); +$tutor-tab-l3: var(--tutor-tab-l3); +$tutor-tab-l3-hover: var(--tutor-tab-l3-hover); +$tutor-tab-l3-active: var(--tutor-tab-l3-active); +$tutor-tab-l3-active-hover: var(--tutor-tab-l3-active-hover); + +// ============================================================================= +// TAB COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-tabs: ( + sidebar-l2: $tutor-tab-sidebar-l2, + sidebar-l2-hover: $tutor-tab-sidebar-l2-hover, + sidebar-l2-active: $tutor-tab-sidebar-l2-active, + sidebar-l4-hover: $tutor-tab-sidebar-l4-hover, + sidebar-l4-active: $tutor-tab-sidebar-l4-active, + l3: $tutor-tab-l3, + l3-hover: $tutor-tab-l3-hover, + l3-active: $tutor-tab-l3-active, + l3-active-hover: $tutor-tab-l3-active-hover, +); diff --git a/assets/core/scss/tokens/_text-colors.scss b/assets/core/scss/tokens/_text-colors.scss new file mode 100644 index 0000000000..feaff10071 --- /dev/null +++ b/assets/core/scss/tokens/_text-colors.scss @@ -0,0 +1,52 @@ +// Design Tokens - Text Colors +// CSS variables for theme-aware text colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-text-primary: var(--tutor-text-primary); +$tutor-text-primary-inverse: var(--tutor-text-primary-inverse); +$tutor-text-secondary: var(--tutor-text-secondary); +$tutor-text-subdued: var(--tutor-text-subdued); +$tutor-text-brand: var(--tutor-text-brand); +$tutor-text-brand-hover: var(--tutor-text-brand-hover); +$tutor-text-brand-secondary: var(--tutor-text-brand-secondary); +$tutor-text-light: var(--tutor-text-light); +$tutor-text-success: var(--tutor-text-success); +$tutor-text-critical: var(--tutor-text-critical); +$tutor-text-critical-hover: var(--tutor-text-critical-hover); +$tutor-text-warning: var(--tutor-text-warning); +$tutor-text-caution: var(--tutor-text-caution); +$tutor-text-exception1: var(--tutor-text-exception1); +$tutor-text-exception2: var(--tutor-text-exception2); +$tutor-text-exception4: var(--tutor-text-exception4); +$tutor-text-exception5: var(--tutor-text-exception5); +$tutor-text-highlighted-hover: var(--tutor-text-highlighted-hover); +$tutor-text-disabled: var(--tutor-text-disabled); + +// ============================================================================= +// TEXT COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-text-colors: ( + primary: $tutor-text-primary, + primary-inverse: $tutor-text-primary-inverse, + secondary: $tutor-text-secondary, + subdued: $tutor-text-subdued, + brand: $tutor-text-brand, + brand-hover: $tutor-text-brand-hover, + brand-secondary: $tutor-text-brand-secondary, + light: $tutor-text-light, + success: $tutor-text-success, + critical: $tutor-text-critical, + critical-hover: $tutor-text-critical-hover, + warning: $tutor-text-warning, + caution: $tutor-text-caution, + exception1: $tutor-text-exception1, + exception2: $tutor-text-exception2, + exception4: $tutor-text-exception4, + exception5: $tutor-text-exception5, + highlighted-hover: $tutor-text-highlighted-hover, + disabled: $tutor-text-disabled, +); \ No newline at end of file diff --git a/assets/core/scss/tokens/_toast.scss b/assets/core/scss/tokens/_toast.scss new file mode 100644 index 0000000000..6643ae411e --- /dev/null +++ b/assets/core/scss/tokens/_toast.scss @@ -0,0 +1,57 @@ +@use './typography' as *; +@use './borders' as *; +@use './shadows' as *; +@use './spacing' as *; +@use './surfaces' as *; +@use './text-colors' as *; +@use './icons' as *; +@use './zIndex' as *; + +$tutor-toast-font: var(--tutor-toast-font, #{$tutor-font-family-body}); +$tutor-toast-z: var(--tutor-toast-z, #{$tutor-z-highest + 1}); +$tutor-toast-gap: var(--tutor-toast-gap, #{$tutor-spacing-4}); +$tutor-toast-padding: var(--tutor-toast-padding, #{$tutor-spacing-5} #{$tutor-spacing-6}); +$tutor-toast-radius: var(--tutor-toast-radius, #{$tutor-radius-full}); +$tutor-toast-icon-size: var(--tutor-toast-icon-size, 36px); +$tutor-toast-min-width: var(--tutor-toast-min-width, 300px); +$tutor-toast-max-width: var(--tutor-toast-max-width, 340px); +$tutor-toast-animation-enter: var(--tutor-toast-animation-enter, 400ms); +$tutor-toast-animation-exit: var(--tutor-toast-animation-exit, 300ms); +$tutor-toast-offset-x: var(--tutor-toast-offset-x, #{$tutor-spacing-6}); +$tutor-toast-offset-y: var(--tutor-toast-offset-y, #{$tutor-spacing-6}); + +$tutor-toast-background: var(--tutor-toast-background, #{$tutor-surface-base}); +$tutor-toast-border: var(--tutor-toast-border, #{$tutor-border-idle}); +$tutor-toast-shadow: var(--tutor-toast-shadow, #{$tutor-shadow-xl}); +$tutor-toast-text: var(--tutor-toast-text, #{$tutor-text-primary}); +$tutor-toast-text-muted: var(--tutor-toast-text-muted, #{$tutor-text-secondary}); + +$tutor-toast-success-background: var(--tutor-toast-success-background, #{$tutor-surface-success}); +$tutor-toast-success-border: var(--tutor-toast-success-border, #{$tutor-border-success}); +$tutor-toast-success-icon: var(--tutor-toast-success-icon, #{$tutor-icon-success-primary}); +$tutor-toast-success-text: var(--tutor-toast-success-text, #{$tutor-text-success}); + +$tutor-toast-error-background: var(--tutor-toast-error-background, #{$tutor-surface-critical}); +$tutor-toast-error-border: var(--tutor-toast-error-border, #{$tutor-border-error}); +$tutor-toast-error-icon: var(--tutor-toast-error-icon, #{$tutor-icon-critical}); +$tutor-toast-error-text: var(--tutor-toast-error-text, #{$tutor-text-critical}); + +$tutor-toast-warning-background: var(--tutor-toast-warning-background, #{$tutor-surface-warning-hover}); +$tutor-toast-warning-border: var(--tutor-toast-warning-border, #{$tutor-border-warning-tertiary}); +$tutor-toast-warning-icon: var(--tutor-toast-warning-icon, #{$tutor-icon-warning}); +$tutor-toast-warning-text: var(--tutor-toast-warning-text, #{$tutor-text-caution}); + +$tutor-toast-info-background: var(--tutor-toast-info-background, #{$tutor-surface-brand-secondary}); +$tutor-toast-info-border: var(--tutor-toast-info-border, #{$tutor-border-brand-tertiary}); +$tutor-toast-info-icon: var(--tutor-toast-info-icon, #{$tutor-icon-brand}); +$tutor-toast-info-text: var(--tutor-toast-info-text, #{$tutor-text-brand}); + +$tutor-toast-loading-background: var(--tutor-toast-loading-background, #{$tutor-surface-l2}); +$tutor-toast-loading-border: var(--tutor-toast-loading-border, #{$tutor-border-hover}); +$tutor-toast-loading-icon: var(--tutor-toast-loading-icon, #{$tutor-icon-idle}); +$tutor-toast-loading-text: var(--tutor-toast-loading-text, #{$tutor-text-primary}); + +$tutor-toast-default-background: var(--tutor-toast-default-background, #{$tutor-surface-base}); +$tutor-toast-default-border: var(--tutor-toast-default-border, #{$tutor-border-idle}); +$tutor-toast-default-icon: var(--tutor-toast-default-icon, #{$tutor-icon-idle}); +$tutor-toast-default-text: var(--tutor-toast-default-text, #{$tutor-text-primary}); diff --git a/assets/core/scss/tokens/_typography.scss b/assets/core/scss/tokens/_typography.scss new file mode 100644 index 0000000000..fbbd525019 --- /dev/null +++ b/assets/core/scss/tokens/_typography.scss @@ -0,0 +1,111 @@ +// Design Tokens - Typography +// Font families, weights, sizes, and line heights + +// ============================================================================= +// FONT FAMILIES +// ============================================================================= + +$tutor-font-family-heading: + var(--tutor-font-family-heading, 'Inter'), + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +$tutor-font-family-body: + var(--tutor-font-family-body, 'Inter'), + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + +// ============================================================================= +// FONT WEIGHTS +// ============================================================================= + +$tutor-font-weight-regular: 400; // Regular +$tutor-font-weight-medium: 500; // Medium +$tutor-font-weight-semibold: 600; // Semi Bold +$tutor-font-weight-bold: 700; // Bold + +// ============================================================================= +// FONT SIZES (converted to rem for font scaling support) +// Base: 16px = 1rem +// ============================================================================= + +$tutor-font-size-d1: 4rem; // 64px ÷ 16 = 4rem +$tutor-font-size-h1: 2.5rem; // 40px ÷ 16 = 2.5rem +$tutor-font-size-h2: 2rem; // 32px ÷ 16 = 2rem +$tutor-font-size-h3: 1.5rem; // 24px ÷ 16 = 1.5rem +$tutor-font-size-h4: 1.25rem; // 20px ÷ 16 = 1.25rem +$tutor-font-size-h5: 1.125rem; // 18px ÷ 16 = 1.125rem +$tutor-font-size-medium: 1rem; // 16px ÷ 16 = 1rem +$tutor-font-size-p1: 1rem; // 16px ÷ 16 = 1rem +$tutor-font-size-small: 0.875rem; // 14px ÷ 16 = 0.875rem +$tutor-font-size-p2: 0.875rem; // 14px ÷ 16 = 0.875rem +$tutor-font-size-tiny: 0.75rem; // 12px ÷ 16 = 0.75rem +$tutor-font-size-tiny-2: 0.625rem; // 10px ÷ 16 = 0.625rem +$tutor-font-size-p3: 0.75rem; // 12px ÷ 16 = 0.75rem + +// ============================================================================= +// LINE HEIGHTS (converted to rem for proportional scaling with fonts) +// Base: 16px = 1rem +// ============================================================================= + +$tutor-line-height-d1: 4.5rem; // 72px ÷ 16 = 4.5rem +$tutor-line-height-h1: 3rem; // 48px ÷ 16 = 3rem +$tutor-line-height-h2: 2.5rem; // 40px ÷ 16 = 2.5rem +$tutor-line-height-h3: 2rem; // 32px ÷ 16 = 2rem +$tutor-line-height-h4: 1.75rem; // 28px ÷ 16 = 1.75rem +$tutor-line-height-h5: 1.625rem; // 26px ÷ 16 = 1.625rem +$tutor-line-height-medium: 1.5rem; // 24px ÷ 16 = 1.5rem +$tutor-line-height-p1: 1.375rem; // 22px ÷ 16 = 1.375rem +$tutor-line-height-small: 1.375rem; // 22px ÷ 16 = 1.375rem +$tutor-line-height-p2: 1.125rem; // 18px ÷ 16 = 1.125rem +$tutor-line-height-tiny: 1.25rem; // 20px ÷ 16 = 1.25rem +$tutor-line-height-tiny-2: 1.125rem; // 18px ÷ 16 = 1.125rem +$tutor-line-height-p3: 1.125rem; // 18px ÷ 16 = 1.125rem + +// ============================================================================= +// TYPOGRAPHY MAPS (for utility generation) +// ============================================================================= + +$tutor-font-sizes: ( + d1: $tutor-font-size-d1, + h1: $tutor-font-size-h1, + h2: $tutor-font-size-h2, + h3: $tutor-font-size-h3, + h4: $tutor-font-size-h4, + h5: $tutor-font-size-h5, + medium: $tutor-font-size-medium, + p1: $tutor-font-size-p1, + small: $tutor-font-size-small, + p2: $tutor-font-size-p2, + tiny: $tutor-font-size-tiny, + tiny-2: $tutor-font-size-tiny-2, + p3: $tutor-font-size-p3, +); + +$tutor-line-heights: ( + d1: $tutor-line-height-d1, + h1: $tutor-line-height-h1, + h2: $tutor-line-height-h2, + h3: $tutor-line-height-h3, + h4: $tutor-line-height-h4, + h5: $tutor-line-height-h5, + medium: $tutor-line-height-medium, + p1: $tutor-line-height-p1, + small: $tutor-line-height-small, + p2: $tutor-line-height-p2, + tiny: $tutor-line-height-tiny, + tiny-2: $tutor-line-height-tiny-2, + p3: $tutor-line-height-p3, +); + +$tutor-font-weights: ( + regular: $tutor-font-weight-regular, + medium: $tutor-font-weight-medium, + semibold: $tutor-font-weight-semibold, + bold: $tutor-font-weight-bold, +); diff --git a/assets/core/scss/tokens/_utility-config.scss b/assets/core/scss/tokens/_utility-config.scss new file mode 100644 index 0000000000..5e4aa8aacf --- /dev/null +++ b/assets/core/scss/tokens/_utility-config.scss @@ -0,0 +1,42 @@ +// Utility Generation Configuration +// Use these maps to limit which utilities are generated for responsive breakpoints +// to keep the build size small. + +@use 'sass:map'; +@use 'breakpoints' as b; +@use 'spacing' as s; +@use 'borders' as bor; + +// Breakpoints to include in responsive utility generation +$tutor-responsive-breakpoints: ( + xl: map.get(b.$tutor-breakpoints, xl), + lg: map.get(b.$tutor-breakpoints, lg), + md: map.get(b.$tutor-breakpoints, md), + sm: map.get(b.$tutor-breakpoints, sm), +); + +// Spacing values to include in responsive utility generation (subset of full scale) +$tutor-responsive-spacing: ( + none: map.get(s.$tutor-spacing, none), + 1: map.get(s.$tutor-spacing, 1), + 2: map.get(s.$tutor-spacing, 2), + 3: map.get(s.$tutor-spacing, 3), + 4: map.get(s.$tutor-spacing, 4), + 5: map.get(s.$tutor-spacing, 5), + 6: map.get(s.$tutor-spacing, 6), + 7: map.get(s.$tutor-spacing, 7), + 8: map.get(s.$tutor-spacing, 8), + 9: map.get(s.$tutor-spacing, 9), + 10: map.get(s.$tutor-spacing, 10), +); + +// Radius values to include in responsive utility generation +$tutor-responsive-radius: ( + none: map.get(bor.$tutor-radius, none), + sm: map.get(bor.$tutor-radius, sm), + md: map.get(bor.$tutor-radius, md), + lg: map.get(bor.$tutor-radius, lg), + 2xl: map.get(bor.$tutor-radius, 2xl), + full: map.get(bor.$tutor-radius, full), +); + diff --git a/assets/core/scss/tokens/_visual.scss b/assets/core/scss/tokens/_visual.scss new file mode 100644 index 0000000000..00cb19e703 --- /dev/null +++ b/assets/core/scss/tokens/_visual.scss @@ -0,0 +1,50 @@ +// Design Tokens - Visual Colors +// CSS variables for theme-aware visual colors + +// ============================================================================= +// CSS VARIABLE REFERENCES (theme-aware) +// ============================================================================= + +$tutor-visual-gray-1: var(--tutor-visual-gray-1); +$tutor-visual-gray-2: var(--tutor-visual-gray-2); +$tutor-visual-gray-3: var(--tutor-visual-gray-3); +$tutor-visual-gray-4: var(--tutor-visual-gray-4); +$tutor-visual-brand-1: var(--tutor-visual-brand-1); +$tutor-visual-brand-2: var(--tutor-visual-brand-2); +$tutor-visual-brand-3: var(--tutor-visual-brand-3); +$tutor-visual-success-1: var(--tutor-visual-success-1); +$tutor-visual-success-2: var(--tutor-visual-success-2); +$tutor-visual-critical-1: var(--tutor-visual-critical-1); +$tutor-visual-critical-2: var(--tutor-visual-critical-2); +$tutor-visual-caution-1: var(--tutor-visual-caution-1); +$tutor-visual-caution-2: var(--tutor-visual-caution-2); +$tutor-visual-caution-3: var(--tutor-visual-caution-3); +$tutor-visual-orange-1: var(--tutor-visual-orange-1); +$tutor-visual-exception-1: var(--tutor-visual-exception-1); +$tutor-visual-exception-2: var(--tutor-visual-exception-2); +$tutor-visual-exception-3: var(--tutor-visual-exception-3); + +// ============================================================================= +// VISUAL COLOR MAP (for utility generation) +// ============================================================================= + +$tutor-visual-colors: ( + gray-1: $tutor-visual-gray-1, + gray-2: $tutor-visual-gray-2, + gray-3: $tutor-visual-gray-3, + gray-4: $tutor-visual-gray-4, + brand-1: $tutor-visual-brand-1, + brand-2: $tutor-visual-brand-2, + brand-3: $tutor-visual-brand-3, + success-1: $tutor-visual-success-1, + success-2: $tutor-visual-success-2, + critical-1: $tutor-visual-critical-1, + critical-2: $tutor-visual-critical-2, + caution-1: $tutor-visual-caution-1, + caution-2: $tutor-visual-caution-2, + caution-3: $tutor-visual-caution-3, + orange-1: $tutor-visual-orange-1, + exception-1: $tutor-visual-exception-1, + exception-2: $tutor-visual-exception-2, + exception-3: $tutor-visual-exception-3, +); diff --git a/assets/core/scss/tokens/_zIndex.scss b/assets/core/scss/tokens/_zIndex.scss new file mode 100644 index 0000000000..5c4c8ea550 --- /dev/null +++ b/assets/core/scss/tokens/_zIndex.scss @@ -0,0 +1,32 @@ +// Design Tokens - z-index +// CSS variables for z-index + +// ============================================================================= +// CSS VARIABLE REFERENCES +// ============================================================================= + +$tutor-z-negative: -1; +$tutor-z-positive: 1; +$tutor-z-dropdown: 2; +$tutor-z-level: 0; +$tutor-z-sidebar: 20; +$tutor-z-header: 10; +$tutor-z-footer: 10; +$tutor-z-modal: 25; +$tutor-z-highest: 99999; + +// ============================================================================= +// Z-INDEX MAP (for utility generation) +// ============================================================================= + +$tutor-z: ( + negative: $tutor-z-negative, + positive: $tutor-z-positive, + dropdown: $tutor-z-dropdown, + level: $tutor-z-level, + sidebar: $tutor-z-sidebar, + header: $tutor-z-header, + footer: $tutor-z-footer, + modal: $tutor-z-modal, + highest: $tutor-z-highest, +); diff --git a/assets/core/scss/utilities/_borders.scss b/assets/core/scss/utilities/_borders.scss new file mode 100644 index 0000000000..2fb0e98a9c --- /dev/null +++ b/assets/core/scss/utilities/_borders.scss @@ -0,0 +1,205 @@ +// Border Utilities +// DRY, RTL-aware, width, style, color, radius, responsive + +@use 'sass:map'; +@use '../tokens' as *; +@use '../mixins' as *; + +// ============================================================================= +// VARIABLES +// ============================================================================= +$border-widths: ( + 2: 2px, + 4: 4px, + 8: 8px, +); + +$border-directions: ( + border: border, + border-t: border-top, + border-r: border-inline-end, + border-b: border-bottom, + border-l: border-inline-start, +); + +$border-styles: (solid, dashed, dotted, double, none); + +$radius-directions: ( + '': ( + border-radius, + ), + 't': ( + border-start-start-radius, + border-start-end-radius, + ), + 'b': ( + border-end-start-radius, + border-end-end-radius, + ), + 'r': ( + border-start-end-radius, + border-end-end-radius, + ), + 'l': ( + border-start-start-radius, + border-end-start-radius, + ), + 'tl': ( + border-start-start-radius, + ), + 'tr': ( + border-start-end-radius, + ), + 'br': ( + border-end-end-radius, + ), + 'bl': ( + border-end-start-radius, + ), +); + +// ============================================================================= +// SMART BORDER UTILITIES +// ============================================================================= +// Generate border-width utilities +@each $width-key, $width-value in $border-widths { + .tutor-border-#{$width-key} { + border: #{$width-value} solid $tutor-border-idle; + } +} + +// Directional borders (default width 1px) +@each $dir, $property in $border-directions { + .tutor-#{$dir} { + #{$property}: 1px solid $tutor-border-idle; + } + .tutor-#{$dir}-0 { + #{$property}: none; + } + + @each $width-key, $width-value in $border-widths { + .tutor-#{$dir}-#{$width-key} { + #{$property}: #{$width-value} solid $tutor-border-idle; + } + } +} + +// ============================================================================= +// BORDER STYLES +// ============================================================================= +@each $style in $border-styles { + .tutor-border-#{$style} { + border-style: $style; + } +} + +// ============================================================================= +// BORDER COLORS +// ============================================================================= +.tutor-border-transparent { + border-color: transparent; +} +.tutor-border-current { + border-color: currentColor; +} + +@each $key, $value in $tutor-border-colors { + .tutor-border-#{$key} { + border-color: $value; + } +} + +// ============================================================================= +// BORDER RADIUS (RTL-aware) +// ============================================================================= +@each $key, $value in $tutor-radius { + @each $abbr, $props in $radius-directions { + $prefix: if($abbr == '', '', $abbr + '-'); + + .tutor-rounded-#{$prefix}#{$key} { + @each $prop in $props { + #{$prop}: $value; + } + } + } +} + +// ============================================================================= +// SEMANTIC BORDERS +// ============================================================================= +.tutor-border-card { + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-lg; +} + +.tutor-border-input { + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-md; + + &:focus { + border-color: $tutor-border-brand-tertiary; + } +} + +.tutor-border-divider { + border-bottom: 1px solid $tutor-border-idle; +} + +// ============================================================================= +// RESPONSIVE BORDER UTILITIES +// ============================================================================= +$responsive-border-dirs: ( + border: border, + border-t: border-top, + border-b: border-bottom, +); + +$responsive-radius-dirs: ( + '': ( + border-radius, + ), + 't': ( + border-start-start-radius, + border-start-end-radius, + ), + 'b': ( + border-end-start-radius, + border-end-end-radius, + ), +); + +@each $breakpoint, $min-width in $tutor-responsive-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + // Border widths & directional borders (Restricted to All, Top, Bottom) + @each $dir, $property in $responsive-border-dirs { + .tutor-#{$breakpoint}-#{$dir} { + #{$property}: 1px solid $tutor-border-idle; + } + .tutor-#{$breakpoint}-#{$dir}-0 { + #{$property}: none; + } + + @each $width-key, $width-value in $border-widths { + .tutor-#{$breakpoint}-#{$dir}-#{$width-key} { + #{$property}: #{$width-value} solid $tutor-border-idle; + } + } + } + + // Border radius (Restricted to All, Top, Bottom + Subset of values) + @each $key, $value in $tutor-responsive-radius { + @each $abbr, $props in $radius-directions { + // We only generate for specific abbreviations to save space + @if map.has-key($responsive-radius-dirs, $abbr) { + $prefix: if($abbr == '', '', $abbr + '-'); + + .tutor-#{$breakpoint}-rounded-#{$prefix}#{$key} { + @each $prop in $props { + #{$prop}: $value; + } + } + } + } + } + } +} diff --git a/assets/core/scss/utilities/_colors.scss b/assets/core/scss/utilities/_colors.scss new file mode 100644 index 0000000000..e0cf3e4121 --- /dev/null +++ b/assets/core/scss/utilities/_colors.scss @@ -0,0 +1,197 @@ +// Color Utilities +// Background, border, and text color utilities with theme awareness + +@use '../tokens' as *; +@use '../mixins' as *; + +// ------------------------------------------------------------ +// Transparent / Current Color Utilities +// ------------------------------------------------------------ +.tutor-bg-transparent { + background-color: transparent; +} + +.tutor-bg-current { + background-color: currentColor; +} + +// ------------------------------------------------------------ +// Brand Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-brand-colors { + .tutor-bg-brand-#{$key} { + background-color: $value; + } + .tutor-text-brand-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Gray Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-gray-colors { + .tutor-bg-gray-#{$key} { + background-color: $value; + } + .tutor-text-gray-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Success Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-success-colors { + .tutor-bg-success-#{$key} { + background-color: $value; + } + .tutor-text-success-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Warning Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-warning-colors { + .tutor-bg-warning-#{$key} { + background-color: $value; + } + .tutor-text-warning-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Error Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-error-colors { + .tutor-bg-error-#{$key} { + background-color: $value; + } + .tutor-text-error-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Exception Colors (Background & Text) +// ------------------------------------------------------------ +@each $key, $value in $tutor-exception-colors { + .tutor-bg-exception-#{$key} { + background-color: $value; + } + .tutor-text-exception-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Surface Colors +// ------------------------------------------------------------ +@each $key, $value in $tutor-surfaces { + .tutor-surface-#{$key} { + background-color: $value; + } +} + +// ------------------------------------------------------------ +// Text Colors +// ------------------------------------------------------------ +@each $key, $value in $tutor-text-colors { + .tutor-text-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Icon Colors +// ------------------------------------------------------------ +@each $key, $value in $tutor-icons { + .tutor-icon-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Actions Colors +// ------------------------------------------------------------ +@each $key, $value in $tutor-actions { + .tutor-actions-#{$key} { + color: $value; + } +} + +// ------------------------------------------------------------ +// Shadow Utilities +// ------------------------------------------------------------ +@each $key, $value in $tutor-shadows { + .tutor-shadow-#{$key} { + box-shadow: $value; + } +} + +// ------------------------------------------------------------ +// Opacity Utilities +// ------------------------------------------------------------ +$opacities: ( + 0: 0, + 25: 0.25, + 50: 0.5, + 75: 0.75, + 100: 1, +); + +// Generate classes +@each $key, $value in $opacities { + .tutor-opacity-#{$key} { + opacity: $value; + } +} + +// ------------------------------------------------------------ +// Hover State Utilities +// ------------------------------------------------------------ +.tutor-hover-surface-l1:hover { + background-color: $tutor-surface-l1-hover; +} + +.tutor-hover-surface-l2:hover { + background-color: $tutor-surface-l2-hover; +} + +.tutor-hover-text-primary:hover { + color: $tutor-text-primary; +} + +.tutor-hover-text-brand:hover { + color: $tutor-text-brand; +} + +.tutor-hover-border-brand:hover { + border-color: $tutor-border-brand; +} + +.tutor-hover-shadow-md:hover { + box-shadow: $tutor-shadow-md; +} + +.tutor-hover-shadow-lg:hover { + box-shadow: $tutor-shadow-lg; +} + +// ------------------------------------------------------------ +// Focus State Utilities +// ------------------------------------------------------------ +.tutor-focus-border-brand:focus { + border-color: $tutor-border-brand; +} + +.tutor-focus-ring:focus { + @include tutor-focus-ring; +} + +.tutor-focus-outline-none:focus { + outline: none; +} diff --git a/assets/core/scss/utilities/_index.scss b/assets/core/scss/utilities/_index.scss new file mode 100644 index 0000000000..9fc2bf76cc --- /dev/null +++ b/assets/core/scss/utilities/_index.scss @@ -0,0 +1,9 @@ +@forward 'borders'; +@forward 'typography'; +@forward 'colors'; +@forward 'layout'; +@forward 'sizing'; +@forward 'spacing'; +@forward 'transform'; +@forward 'transition'; +@forward 'zIndex'; diff --git a/assets/core/scss/utilities/_layout.scss b/assets/core/scss/utilities/_layout.scss new file mode 100644 index 0000000000..4de93bbb90 --- /dev/null +++ b/assets/core/scss/utilities/_layout.scss @@ -0,0 +1,429 @@ +// Layout Utilities +// Flexbox, grid, positioning, display, and responsive utilities + +@use '../tokens' as *; +@use '../mixins' as *; + +// ------------------------ +// Utility Maps +// ------------------------ + +$display-utils: ( + block: block, + inline-block: inline-block, + inline: inline, + flex: flex, + inline-flex: inline-flex, + grid: grid, + inline-grid: inline-grid, + hidden: none, +); + +$flex-direction-utils: ( + flex-row: row, + flex-row-reverse: row-reverse, + flex-column: column, + flex-column-reverse: column-reverse, +); + +$flex-wrap-utils: ( + wrap: wrap, + wrap-reverse: wrap-reverse, + nowrap: nowrap, +); + +$flex-grow-utils: (0, 1); +$flex-shrink-utils: (0, 1); + +$justify-utils: ( + justify-start: flex-start, + justify-end: flex-end, + justify-center: center, + justify-between: space-between, + justify-around: space-around, + justify-evenly: space-evenly, +); + +$align-items-utils: ( + items-normal: normal, + items-start: flex-start, + items-end: flex-end, + items-center: center, + items-baseline: baseline, + items-stretch: stretch, +); + +$align-content-utils: ( + start: flex-start, + end: flex-end, + center: center, + between: space-between, + around: space-around, + evenly: space-evenly, +); + +$align-self-utils: ( + auto: auto, + start: flex-start, + end: flex-end, + center: center, + stretch: stretch, + baseline: baseline, +); + +$grid-cols: (1, 2, 3, 4, 5, 6, 12); + +$grid-split-utils: ( + '1-5-1': 1.5fr 1fr, + '2-1': 2fr 1fr, + '1-2': 1fr 2fr, +); + +// ------------------------ +// Base Utilities +// ------------------------ + +// Display +@each $name, $value in $display-utils { + .tutor-#{$name} { + display: $value; + } + .tutor-force-#{$name} { + display: $value !important; + } +} + +// Flex direction +@each $name, $dir in $flex-direction-utils { + .tutor-#{$name} { + flex-direction: $dir; + } +} + +// Flex wrap +@each $name, $dir in $flex-wrap-utils { + .tutor-flex-#{$name} { + flex-wrap: $dir; + } +} + +// Flex grow +@each $value in $flex-grow-utils { + .tutor-flex-grow-#{$value} { + flex-grow: $value; + } +} + +// Flex shrink +@each $value in $flex-shrink-utils { + .tutor-flex-shrink-#{$value} { + flex-shrink: $value; + } +} + +// Flex shorthand +.tutor-flex-1 { + flex: 1 1 0%; +} +.tutor-flex-auto { + flex: 1 1 auto; +} +.tutor-flex-initial { + flex: 0 1 auto; +} +.tutor-flex-none { + flex: none; +} +.tutor-flex-center { + @include tutor-flex(row, center, center); +} +.tutor-flex-center-col { + @include tutor-flex(column, center, center); +} + +// Justify content +@each $name, $value in $justify-utils { + .tutor-#{$name} { + justify-content: $value; + } +} + +// Align items +@each $name, $value in $align-items-utils { + .tutor-#{$name} { + align-items: $value; + } +} + +// Align content +@each $name, $value in $align-content-utils { + .tutor-content-#{$name} { + align-content: $value; + } +} + +// Align self +@each $name, $value in $align-self-utils { + .tutor-self-#{$name} { + align-self: $value; + } +} + +// Gap utilities +@each $key, $value in $tutor-spacing { + .tutor-gap-#{$key} { + gap: $value; + } + .tutor-gap-x-#{$key} { + column-gap: $value; + } + .tutor-gap-y-#{$key} { + row-gap: $value; + } +} + +@each $count in $grid-cols { + .tutor-grid-cols-#{$count} { + grid-template-columns: repeat($count, minmax(0, 1fr)); + } +} + +// Custom splits +@each $name, $value in $grid-split-utils { + .tutor-grid-cols-#{$name} { + grid-template-columns: $value; + } +} + +.tutor-grid-cols-none { + grid-template-columns: none; +} + +// Grid column span +@for $i from 1 through 6 { + .tutor-col-span-#{$i} { + grid-column: span #{$i} / span #{$i}; + } +} +.tutor-col-span-full { + grid-column: 1 / -1; +} +.tutor-col-auto { + grid-column: auto; +} + +// ------------------------ +// Positioning Utilities +// ------------------------ + +$position-values: static, fixed, absolute, relative, sticky; +@each $pos in $position-values { + .tutor-#{$pos} { + position: $pos; + } +} + +// Insets +$inset-sides: top, right, bottom, left; +@each $side in $inset-sides { + .tutor-#{$side}-0 { + @if $side == 'right' { + inset-inline-end: 0; + } @else if $side == 'left' { + inset-inline-start: 0; + } @else { + #{$side}: 0; + } + } + .tutor-#{$side}-auto { + @if $side == 'right' { + inset-inline-end: auto; + } @else if $side == 'left' { + inset-inline-start: auto; + } @else { + #{$side}: auto; + } + } +} +@each $key, $value in $tutor-spacing { + @each $side in $inset-sides { + .tutor-#{$side}-#{$key} { + @if $side == 'right' { + inset-inline-end: $value; + } @else if $side == 'left' { + inset-inline-start: $value; + } @else { + #{$side}: $value; + } + } + } +} + +.tutor-inset-0 { + top: 0; + inset-inline-start: 0; + bottom: 0; + inset-inline-end: 0; +} +.tutor-inset-auto { + top: auto; + inset-inline-start: auto; + bottom: auto; + inset-inline-end: auto; +} + +// ------------------------ +// Overflow Utilities +// ------------------------ + +$overflow-types: auto, hidden, visible, scroll; +@each $type in $overflow-types { + .tutor-overflow-#{$type} { + overflow: $type; + } + .tutor-overflow-x-#{$type} { + overflow-x: $type; + } + .tutor-overflow-y-#{$type} { + overflow-y: $type; + } +} + +// ------------------------ +// Float Utilities +// ------------------------ + +.tutor-float-start { + float: inline-start; +} +.tutor-float-end { + float: inline-end; +} +.tutor-float-left { + float: left; +} +.tutor-float-right { + float: right; +} +.tutor-float-none { + float: none; +} + +// ------------------------ +// Icon utility +// ------------------------ + +.tutor-icon { + @include tutor-flex(row, center, center); + flex-shrink: 0; +} + +// ------------------------ +// Aspect ratio utility +// ------------------------ +.tutor-ratio { + content: ' '; + position: relative; + width: 100%; + display: block; + + &-16x9 { + padding-top: 56.25%; + } + + &-4x3 { + padding-top: 75%; + } + + &-3x2 { + padding-top: 66.66%; + } + + &-3x1 { + padding-top: 33.33%; + } + + &-1x1 { + padding-top: 100%; + } + + > * { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + } + + > img { + object-fit: cover; + object-position: center; + } +} + +// ------------------------ +// Responsive Utilities +// ------------------------ + +@each $breakpoint, $min-width in $tutor-responsive-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + // Display utilities + @each $name, $value in $display-utils { + .tutor-#{$breakpoint}-#{$name} { + display: $value; + } + .tutor-force-#{$breakpoint}-#{$name} { + display: $value !important; + } + } + + // Flex direction utilities + @each $name, $dir in $flex-direction-utils { + .tutor-#{$breakpoint}-#{$name} { + flex-direction: $dir; + } + } + + // Justify content utilities + @each $name, $value in $justify-utils { + .tutor-#{$breakpoint}-#{$name} { + justify-content: $value; + } + } + + // Align items utilities + @each $name, $value in $align-items-utils { + .tutor-#{$breakpoint}-#{$name} { + align-items: $value; + } + } + + // Gap utilities + @each $name, $value in $tutor-responsive-spacing { + .tutor-#{$breakpoint}-gap-#{$name} { + gap: $value; + } + } + + // Grid column utilities + @each $count in $grid-cols { + .tutor-#{$breakpoint}-grid-cols-#{$count} { + grid-template-columns: repeat($count, minmax(0, 1fr)); + } + } + + // Flex shorthand + .tutor-#{$breakpoint}-flex-1 { + flex: 1 1 0%; + } + .tutor-#{$breakpoint}-flex-auto { + flex: 1 1 auto; + } + .tutor-#{$breakpoint}-flex-initial { + flex: 0 1 auto; + } + .tutor-#{$breakpoint}-flex-none { + flex: none; + } + } +} diff --git a/assets/core/scss/utilities/_sizing.scss b/assets/core/scss/utilities/_sizing.scss new file mode 100644 index 0000000000..d2e6846baa --- /dev/null +++ b/assets/core/scss/utilities/_sizing.scss @@ -0,0 +1,196 @@ +// Sizing Utilities +// Width, height, and sizing utilities + +@use '../tokens' as *; +@use '../mixins' as *; + +// ------------------------ +// Width Utilities +// ------------------------ + +$fractional-widths: ( + 1\/2: 50%, + 1\/3: 33.333333%, + 2\/3: 66.666667%, + 1\/4: 25%, + 3\/4: 75%, + 1\/5: 20%, + 2\/5: 40%, + 3\/5: 60%, + 4\/5: 80%, + 1\/6: 16.666667%, + 5\/6: 83.333333% +); + +$fixed-widths: ( + 0: 0, + auto: auto, + full: 100%, + screen: 100vw, + min: min-content, + max: max-content, + fit: fit-content, +); + +// Base width utilities +@each $key, $value in $fixed-widths { + .tutor-w-#{$key} { + width: $value; + } +} + +@each $key, $value in $fractional-widths { + .tutor-w-#{$key} { + width: $value; + } +} + +// Spacing-based widths +@each $key, $value in $tutor-spacing { + .tutor-w-#{$key} { + width: $value; + } +} + +// ------------------------ +// Height Utilities +// ------------------------ + +$fixed-heights: ( + 0: 0, + auto: auto, + full: 100%, + screen: 100vh, + min: min-content, + max: max-content, + fit: fit-content, +); + +@each $key, $value in $fixed-heights { + .tutor-h-#{$key} { + height: $value; + } +} + +// Spacing-based heights +@each $key, $value in $tutor-spacing { + .tutor-h-#{$key} { + height: $value; + } +} + +// ------------------------ +// Min/Max Width & Height +// ------------------------ + +$min-widths: ( + 0: 0, + full: 100%, + min: min-content, + max: max-content, + fit: fit-content, +); + +$max-widths: ( + 0: 0, + none: none, + xs: 320px, + sm: 384px, + md: 448px, + lg: 512px, + xl: 576px, + 2xl: 672px, + 3xl: 768px, + 4xl: 896px, + 5xl: 1024px, + 6xl: 1152px, + 7xl: 1280px, + full: 100%, + min: min-content, + max: max-content, + fit: fit-content, + prose: 65ch, + screen-sm: 640px, + screen-md: 768px, + screen-lg: 1024px, + screen-xl: 1280px, + screen-2xl: 1536px, +); + +$min-heights: ( + 0: 0, + full: 100%, + screen: 100vh, + min: min-content, + max: max-content, + fit: fit-content, +); + +$max-heights: ( + 0: 0, + full: 100%, + screen: 100vh, + min: min-content, + max: max-content, + fit: fit-content, +); + +// Min-width +@each $key, $value in $min-widths { + .tutor-min-w-#{$key} { + min-width: $value; + } +} + +// Max-width +@each $key, $value in $max-widths { + .tutor-max-w-#{$key} { + max-width: $value; + } +} + +// Min-height +@each $key, $value in $min-heights { + .tutor-min-h-#{$key} { + min-height: $value; + } +} + +// Max-height +@each $key, $value in $max-heights { + .tutor-max-h-#{$key} { + max-height: $value; + } +} + +// ------------------------ +// Responsive Sizing Utilities +// ------------------------ + +$responsive-widths: map-merge($fixed-widths, $fractional-widths); +$responsive-heights: $fixed-heights; + +@each $breakpoint, $min-width in $tutor-responsive-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + // Width + @each $key, $value in $responsive-widths { + .tutor-#{$breakpoint}-w-#{$key} { + width: $value; + } + } + + // Height + @each $key, $value in $responsive-heights { + .tutor-#{$breakpoint}-h-#{$key} { + height: $value; + } + } + + // Max-width + @each $key, $value in $max-widths { + .tutor-#{$breakpoint}-max-w-#{$key} { + max-width: $value; + } + } + } +} diff --git a/assets/core/scss/utilities/_spacing.scss b/assets/core/scss/utilities/_spacing.scss new file mode 100644 index 0000000000..ee986d7df1 --- /dev/null +++ b/assets/core/scss/utilities/_spacing.scss @@ -0,0 +1,199 @@ +// Spacing Utilities +// Margin, padding, and negative margin utilities + +@use '../tokens' as *; +@use '../mixins' as *; + +// Margin utilities + +@each $key, $value in $tutor-spacing { + .tutor-m-#{$key} { + margin: $value; + } + + .tutor-mt-#{$key} { + margin-top: $value; + } + + .tutor-mr-#{$key} { + margin-inline-end: $value; + } + + .tutor-mb-#{$key} { + margin-bottom: $value; + } + + .tutor-ml-#{$key} { + margin-inline-start: $value; + } + + .tutor-mx-#{$key} { + margin-inline: $value; + } + + .tutor-my-#{$key} { + margin-top: $value; + margin-bottom: $value; + } +} + +// Padding utilities +@each $key, $value in $tutor-spacing { + .tutor-p-#{$key} { + padding: $value; + } + + .tutor-pt-#{$key} { + padding-top: $value; + } + + .tutor-pr-#{$key} { + padding-inline-end: $value; + } + + .tutor-pb-#{$key} { + padding-bottom: $value; + } + + .tutor-pl-#{$key} { + padding-inline-start: $value; + } + + .tutor-px-#{$key} { + padding-inline: $value; + } + + .tutor-py-#{$key} { + padding-top: $value; + padding-bottom: $value; + } +} + +// Negative margins +@each $key, $value in $tutor-spacing { + @if $key !=0 { + .-tutor-m-#{$key} { + margin: -$value; + } + + .-tutor-mt-#{$key} { + margin-top: -$value; + } + + .-tutor-mr-#{$key} { + margin-inline-end: -$value; + } + + .-tutor-mb-#{$key} { + margin-bottom: -$value; + } + + .-tutor-ml-#{$key} { + margin-inline-start: -$value; + } + + .-tutor-mx-#{$key} { + margin-inline: -$value; + } + + .-tutor-my-#{$key} { + margin-top: -$value; + margin-bottom: -$value; + } + } +} + +// Auto margins +.tutor-m-auto { + margin: auto; +} + +.tutor-mt-auto { + margin-top: auto; +} + +.tutor-mr-auto { + margin-inline-end: auto; +} + +.tutor-mb-auto { + margin-bottom: auto; +} + +.tutor-ml-auto { + margin-inline-start: auto; +} + +.tutor-mx-auto { + margin-inline: auto; +} + +.tutor-my-auto { + margin-top: auto; + margin-bottom: auto; +} + +// Responsive spacing utilities +@each $breakpoint, $min-width in $tutor-responsive-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + @each $space-key, $space-value in $tutor-responsive-spacing { + .tutor-#{$breakpoint}-m-#{$space-key} { + margin: $space-value; + } + + .tutor-#{$breakpoint}-mt-#{$space-key} { + margin-top: $space-value; + } + + .tutor-#{$breakpoint}-mr-#{$space-key} { + margin-inline-end: $space-value; + } + + .tutor-#{$breakpoint}-mb-#{$space-key} { + margin-bottom: $space-value; + } + + .tutor-#{$breakpoint}-ml-#{$space-key} { + margin-inline-start: $space-value; + } + + .tutor-#{$breakpoint}-mx-#{$space-key} { + margin-inline: $space-value; + } + + .tutor-#{$breakpoint}-my-#{$space-key} { + margin-top: $space-value; + margin-bottom: $space-value; + } + + .tutor-#{$breakpoint}-p-#{$space-key} { + padding: $space-value; + } + + .tutor-#{$breakpoint}-pt-#{$space-key} { + padding-top: $space-value; + } + + .tutor-#{$breakpoint}-pr-#{$space-key} { + padding-inline-end: $space-value; + } + + .tutor-#{$breakpoint}-pb-#{$space-key} { + padding-bottom: $space-value; + } + + .tutor-#{$breakpoint}-pl-#{$space-key} { + padding-inline-start: $space-value; + } + + .tutor-#{$breakpoint}-px-#{$space-key} { + padding-inline: $space-value; + } + + .tutor-#{$breakpoint}-py-#{$space-key} { + padding-top: $space-value; + padding-bottom: $space-value; + } + } + } +} diff --git a/assets/core/scss/utilities/_transform.scss b/assets/core/scss/utilities/_transform.scss new file mode 100644 index 0000000000..b1b7005562 --- /dev/null +++ b/assets/core/scss/utilities/_transform.scss @@ -0,0 +1,133 @@ +// Transform Utilities +// Utilities for transform properties + +@use '../tokens' as *; +@use '../mixins' as *; + +// Transform origin utilities +.tutor-origin-center { + transform-origin: center; +} + +.tutor-origin-top { + transform-origin: top; +} + +.tutor-origin-top-right { + transform-origin: top right; +} + +.tutor-origin-right { + transform-origin: right; +} + +.tutor-origin-bottom-right { + transform-origin: bottom right; +} + +.tutor-origin-bottom { + transform-origin: bottom; +} + +.tutor-origin-bottom-left { + transform-origin: bottom left; +} + +.tutor-origin-left { + transform-origin: left; +} + +.tutor-origin-top-left { + transform-origin: top left; +} + +// Scale utilities +$tutor-scale-values: ( + 0: 0, + 50: 0.5, + 75: 0.75, + 90: 0.9, + 95: 0.95, + 100: 1, + 105: 1.05, + 110: 1.1, + 125: 1.25, + 150: 1.5, +); + +@each $key, $value in $tutor-scale-values { + .tutor-scale-#{$key} { + transform: scale($value); + } + + .tutor-scale-x-#{$key} { + transform: scaleX($value); + } + + .tutor-scale-y-#{$key} { + transform: scaleY($value); + } +} + +// Rotate utilities +$tutor-rotate-values: ( + 0: 0deg, + 45: 45deg, + 90: 90deg, + 180: 180deg, + 270: 270deg, +); + +@each $key, $value in $tutor-rotate-values { + .tutor-rotate-#{$key} { + transform: rotate($value); + } +} + +@each $key, $value in $tutor-spacing { + .tutor-translate-x-#{$key} { + transform: translateX($value); + } + + .tutor-translate-y-#{$key} { + transform: translateY($value); + } +} + +// Skew utilities +$tutor-skew-values: ( + 0: 0deg, + 1: 1deg, + 2: 2deg, + 3: 3deg, + 6: 6deg, + 12: 12deg, +); + +@each $key, $value in $tutor-skew-values { + .tutor-skew-x-#{$key} { + transform: skewX($value); + } + + .tutor-skew-y-#{$key} { + transform: skewY($value); + } +} + +// Transform style utilities +.tutor-transform-flat { + transform-style: flat; +} + +.tutor-transform-3d { + transform-style: preserve-3d; +} + +// Backface visibility utilities +.tutor-backface-visible { + backface-visibility: visible; +} + +.tutor-backface-hidden { + backface-visibility: hidden; +} diff --git a/assets/core/scss/utilities/_transition.scss b/assets/core/scss/utilities/_transition.scss new file mode 100644 index 0000000000..6f211e25fd --- /dev/null +++ b/assets/core/scss/utilities/_transition.scss @@ -0,0 +1,77 @@ +// Transition Utilities +// Utilities for transition properties + +@use '../tokens' as *; +@use '../mixins' as *; + +// Transition property utilities +.tutor-transition-none { + transition-property: none; +} + +.tutor-transition-all { + @include tutor-transition(all); +} + +.tutor-transition { + @include tutor-transition((background-color, border-color, color, fill, stroke, opacity, box-shadow, transform)); +} + +.tutor-transition-colors { + @include tutor-transition((background-color, border-color, color, fill, stroke)); +} + +.tutor-transition-opacity { + @include tutor-transition(opacity); +} + +.tutor-transition-shadow { + @include tutor-transition(box-shadow); +} + +.tutor-transition-transform { + @include tutor-transition(transform); +} + +// Transition duration utilities +$tutor-transition-values: ( + 75: 75ms, + 100: 100ms, + 150: 150ms, + 200: 200ms, + 300: 300ms, + 500: 500ms, + 700: 700ms, + 1000: 1000ms, +); + +@each $key, $value in $tutor-transition-values { + .tutor-duration-#{$key} { + transition-duration: $value; + } +} + +@each $key, $value in $tutor-transition-values { + .tutor-delay-#{$key} { + transition-delay: $value; + } +} + +// Animation utilities +.tutor-animate-none { + animation: none; +} + +.tutor-animate-spin { + animation: tutor-spin 1s linear infinite; +} + +// Keyframe definitions +@keyframes tutor-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/assets/core/scss/utilities/_typography.scss b/assets/core/scss/utilities/_typography.scss new file mode 100644 index 0000000000..d47633fdd9 --- /dev/null +++ b/assets/core/scss/utilities/_typography.scss @@ -0,0 +1,235 @@ +@use 'sass:map'; +@use '../tokens' as *; +@use '../mixins' as *; + +$text-align-utils: ( + left: start, + right: end, + start: start, + end: end, + center: center, + justify: justify, +); + +.tutor-h1 { + @include tutor-typography('h1', 'bold', 'primary', 'heading'); + margin: $tutor-spacing-none; +} + +.tutor-h2 { + @include tutor-typography('h2', 'bold', 'primary', 'heading'); + margin: $tutor-spacing-none; +} + +.tutor-h3 { + @include tutor-typography('h3', 'semibold', 'primary', 'heading'); + margin: $tutor-spacing-none; +} + +.tutor-h4 { + @include tutor-typography('h4', 'semibold', 'primary', 'heading'); + margin: $tutor-spacing-none; +} + +.tutor-h5 { + @include tutor-typography('h5', 'medium', 'primary', 'heading'); + margin: $tutor-spacing-none; +} + +.tutor-p1 { + @include tutor-typography('p1'); +} + +.tutor-p2 { + @include tutor-typography('p2'); +} + +.tutor-p3 { + @include tutor-typography('p3'); +} + +.tutor-medium { + @include tutor-typography('medium'); +} + +.tutor-small { + @include tutor-typography('small'); +} + +.tutor-tiny { + @include tutor-typography('tiny'); +} + +.tutor-tiny-2 { + @include tutor-typography('tiny-2'); +} + +// Font size utilities +@each $key, $value in $tutor-font-sizes { + .tutor-text-#{$key} { + font-size: $value; + line-height: map.get($tutor-line-heights, $key); + } +} + +// Font weight utilities +@each $key, $value in $tutor-font-weights { + .tutor-font-#{$key} { + font-weight: $value; + } +} + +// Text alignment (RTL-aware) +@each $name, $value in $text-align-utils { + .tutor-text-#{$name} { + text-align: $value; + } +} + +// Text decoration +.tutor-underline { + text-decoration: underline; +} + +.tutor-line-through { + text-decoration: line-through; +} + +.tutor-no-underline { + text-decoration: none; +} + +// Text transform +.tutor-uppercase { + text-transform: uppercase; +} + +.tutor-lowercase { + text-transform: lowercase; +} + +.tutor-capitalize { + text-transform: capitalize; +} + +.tutor-normal-case { + text-transform: none; +} + +// Text overflow +.tutor-truncate { + @include tutor-text-truncate; +} + +.tutor-text-ellipsis { + text-overflow: ellipsis; +} + +.tutor-text-clip { + text-overflow: clip; +} + +// Line clamp utilities +.tutor-line-clamp-1 { + @include tutor-text-clamp(1); +} + +.tutor-line-clamp-2 { + @include tutor-text-clamp(2); +} + +.tutor-line-clamp-3 { + @include tutor-text-clamp(3); +} + +.tutor-line-clamp-4 { + @include tutor-text-clamp(4); +} + +.tutor-line-clamp-5 { + @include tutor-text-clamp(5); +} + +.tutor-line-clamp-6 { + @include tutor-text-clamp(6); +} + +// White space +.tutor-whitespace-normal { + white-space: normal; +} + +.tutor-whitespace-nowrap { + white-space: nowrap; +} + +.tutor-whitespace-pre { + white-space: pre; +} + +.tutor-whitespace-pre-line { + white-space: pre-line; +} + +.tutor-whitespace-pre-wrap { + white-space: pre-wrap; +} + +// Word break +.tutor-break-normal { + overflow-wrap: normal; + word-break: normal; +} + +.tutor-break-words { + overflow-wrap: break-word; +} + +.tutor-break-all { + word-break: break-all; +} + +// List styles +.tutor-list-none { + list-style-type: none; +} + +.tutor-list-disc { + list-style-type: disc; +} + +.tutor-list-decimal { + list-style-type: decimal; +} + +.tutor-list-inside { + list-style-position: inside; +} + +.tutor-list-outside { + list-style-position: outside; +} + +// Responsive typography utilities +@each $breakpoint, $min-width in $tutor-responsive-breakpoints { + @include tutor-breakpoint-down($breakpoint) { + @each $size-key, $size-value in $tutor-font-sizes { + .tutor-#{$breakpoint}-text-#{$size-key} { + font-size: $size-value; + line-height: map.get($tutor-line-heights, $size-key); + } + } + + @each $weight-key, $weight-value in $tutor-font-weights { + .tutor-#{$breakpoint}-font-#{$weight-key} { + font-weight: $weight-value; + } + } + + @each $name, $align-value in $text-align-utils { + .tutor-#{$breakpoint}-text-#{$name} { + text-align: $align-value; + } + } + } +} diff --git a/assets/core/scss/utilities/_zIndex.scss b/assets/core/scss/utilities/_zIndex.scss new file mode 100644 index 0000000000..aae7661281 --- /dev/null +++ b/assets/core/scss/utilities/_zIndex.scss @@ -0,0 +1,7 @@ +@use '../tokens' as *; + +@each $key, $value in $tutor-z { + .tutor-z-#{$key} { + z-index: $value; + } +} \ No newline at end of file diff --git a/assets/core/ts/ComponentRegistry.ts b/assets/core/ts/ComponentRegistry.ts new file mode 100644 index 0000000000..4425f24266 --- /dev/null +++ b/assets/core/ts/ComponentRegistry.ts @@ -0,0 +1,148 @@ +import { type Alpine } from 'alpinejs'; + +import { type AlpineComponentMeta, type LazyComponentLoader, type ServiceMeta, type TutorCore } from '@Core/ts/types'; +import { makeFirstCharacterUpperCase } from '@Core/ts/utils/string'; + +interface RegisterAllOptions { + components?: AlpineComponentMeta[]; + services?: ServiceMeta[]; +} + +type RegistryType = 'component' | 'service'; + +interface GetOptions { + name: string; + type: RegistryType; +} + +interface RegisterOptions { + type: RegistryType; + meta: AlpineComponentMeta | ServiceMeta; +} + +class Registry { + private components = new Map(); + private lazyComponents = new Map(); + private loadingComponents = new Map>(); + private services = new Map(); + + register({ type, meta }: RegisterOptions): void { + if (type === 'component') { + const componentMeta = meta as AlpineComponentMeta; + if (!this.components.has(componentMeta.name)) { + this.components.set(componentMeta.name, componentMeta); + } + } else { + const serviceMeta = meta as ServiceMeta; + if (!this.services.has(serviceMeta.name)) { + this.services.set(serviceMeta.name, serviceMeta); + this.exposeToWindow({ type: 'service', items: [serviceMeta] }); + } + } + } + + registerLazy(loaders: Record): void { + Object.entries(loaders).forEach(([name, loader]) => { + this.lazyComponents.set(name, loader); + }); + } + + registerAll({ components = [], services = [] }: RegisterAllOptions): void { + for (const component of components) { + this.register({ type: 'component', meta: component }); + } + for (const service of services) { + this.register({ type: 'service', meta: service }); + } + } + + get({ name, type }: GetOptions): AlpineComponentMeta | T | undefined { + const map = type === 'component' ? this.components : this.services; + const item = map.get(name); + return type === 'service' ? ((item as ServiceMeta)?.instance as T) : (item as AlpineComponentMeta); + } + + has({ name, type }: GetOptions): boolean { + return type === 'component' ? this.components.has(name) : this.services.has(name); + } + + async loadComponent(name: string): Promise { + // Already registered. + if (this.components.has(name)) { + return; + } + + // Already being loaded. + const existingPromise = this.loadingComponents.get(name); + if (existingPromise) { + return existingPromise; + } + + const loader = this.lazyComponents.get(name); + + if (!loader) { + return; + } + + const loadingPromise = (async () => { + try { + const meta = await loader(); + + this.register({ + type: 'component', + meta, + }); + } finally { + this.loadingComponents.delete(name); + } + })(); + + this.loadingComponents.set(name, loadingPromise); + + return loadingPromise; + } + + async loadComponents(names: string[]): Promise { + await Promise.all(names.map((name) => this.loadComponent(name))); + } + + private exposeToWindow({ type, items }: { type: RegistryType; items: (AlpineComponentMeta | ServiceMeta)[] }): void { + if (typeof window === 'undefined') return; + + const TutorCore: TutorCore = window.TutorCore || {}; + + for (const meta of items) { + if (type === 'service') { + TutorCore[meta.name] = (meta as ServiceMeta).instance; + continue; + } + + if ((meta as AlpineComponentMeta).global) { + TutorCore[meta.name] = (meta as AlpineComponentMeta).component; + } + } + + window.TutorCore = TutorCore; + } + + exposeComponents(componentNames?: string[]): void { + const components = componentNames + ? Array.from(this.components.values()).filter((m) => componentNames.includes(m.name)) + : Array.from(this.components.values()); + + this.exposeToWindow({ type: 'component', items: components }); + } + + initWithAlpine(Alpine: Alpine): void { + for (const meta of Array.from(this.components.values())) { + Alpine.data(`tutor${makeFirstCharacterUpperCase(meta.name)}`, meta.component); + } + + this.exposeToWindow({ + type: 'component', + items: Array.from(this.components.values()), + }); + } +} + +export const TutorComponentRegistry = new Registry(); diff --git a/assets/core/ts/components/accordion.ts b/assets/core/ts/components/accordion.ts new file mode 100644 index 0000000000..0d3f09612e --- /dev/null +++ b/assets/core/ts/components/accordion.ts @@ -0,0 +1,104 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; + +export interface AccordionConfig { + multiple?: boolean; + defaultOpen?: number[]; +} + +export interface AlpineAccordionData { + openItems: number[]; + multiple: boolean; + $el?: HTMLElement; + toggle: (index: number) => void; + isOpen: (index: number) => boolean; + handleKeydown: (event: KeyboardEvent, index: number) => void; + focusNext: (currentIndex: number) => void; + focusPrevious: (currentIndex: number) => void; + focusFirst: () => void; + focusLast: () => void; +} + +export function createAccordion(config: AccordionConfig = {}): AlpineAccordionData { + return { + openItems: config.defaultOpen || ([] as number[]), + multiple: config.multiple !== false, // Default to true if not specified + $el: undefined as HTMLElement | undefined, + + toggle(index: number): void { + if (this.openItems.includes(index)) { + this.openItems = this.openItems.filter((i: number) => i !== index); + } else { + if (!this.multiple) { + this.openItems = [index]; + } else { + this.openItems.push(index); + } + } + }, + + isOpen(index: number): boolean { + return this.openItems.includes(index); + }, + + handleKeydown(event: KeyboardEvent, index: number): void { + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + this.toggle(index); + break; + case 'ArrowDown': + event.preventDefault(); + this.focusNext(index); + break; + case 'ArrowUp': + event.preventDefault(); + this.focusPrevious(index); + break; + case 'Home': + event.preventDefault(); + this.focusFirst(); + break; + case 'End': + event.preventDefault(); + this.focusLast(); + break; + } + }, + + focusNext(currentIndex: number): void { + const triggers = this.$el?.querySelectorAll('.tutor-accordion-trigger'); + if (triggers) { + const nextIndex = currentIndex < triggers.length - 1 ? currentIndex + 1 : 0; + (triggers[nextIndex] as HTMLElement).focus(); + } + }, + + focusPrevious(currentIndex: number): void { + const triggers = this.$el?.querySelectorAll('.tutor-accordion-trigger'); + if (triggers) { + const prevIndex = currentIndex > 0 ? currentIndex - 1 : triggers.length - 1; + (triggers[prevIndex] as HTMLElement).focus(); + } + }, + + focusFirst(): void { + const triggers = this.$el?.querySelectorAll('.tutor-accordion-trigger'); + if (triggers && triggers.length > 0) { + (triggers[0] as HTMLElement).focus(); + } + }, + + focusLast(): void { + const triggers = this.$el?.querySelectorAll('.tutor-accordion-trigger'); + if (triggers && triggers.length > 0) { + (triggers[triggers.length - 1] as HTMLElement).focus(); + } + }, + }; +} + +export const accordionMeta: AlpineComponentMeta = { + name: 'accordion', + component: createAccordion, +}; diff --git a/assets/core/ts/components/calendar.ts b/assets/core/ts/components/calendar.ts new file mode 100644 index 0000000000..e541a334d2 --- /dev/null +++ b/assets/core/ts/components/calendar.ts @@ -0,0 +1,609 @@ +import { __ } from '@wordpress/i18n'; +import dayjs from 'dayjs'; +import { type Calendar, Calendar as VanillaCalendar, type Options } from 'vanilla-calendar-pro'; + +import { DateFormats } from '@Core/ts/date-formats'; +import { type AlpineComponentMeta } from '@Core/ts/types'; + +import { tutorConfig } from '@TutorShared/config/config'; + +// @ts-ignore +import 'vanilla-calendar-pro/styles/index.css'; + +const PRESETS = { + ALL_TIME: 'all-time', + YESTERDAY: 'yesterday', + LAST_7: 'last-7', + LAST_14: 'last-14', + LAST_30: 'last-30', + THIS_MONTH: 'this-month', + LAST_MONTH: 'last-month', + LAST_YEAR: 'last-year', +} as const; + +type Preset = (typeof PRESETS)[keyof typeof PRESETS]; + +const PRESET_LABELS: Record = { + [PRESETS.ALL_TIME]: __('All Time', 'tutor'), + [PRESETS.YESTERDAY]: __('Yesterday', 'tutor'), + [PRESETS.LAST_7]: __('Last 7 days', 'tutor'), + [PRESETS.LAST_14]: __('Last 14 days', 'tutor'), + [PRESETS.LAST_30]: __('Last 30 days', 'tutor'), + [PRESETS.THIS_MONTH]: __('This month', 'tutor'), + [PRESETS.LAST_MONTH]: __('Last month', 'tutor'), + [PRESETS.LAST_YEAR]: __('Last year', 'tutor'), +}; + +const TUTOR_CALENDAR_SELECTORS = { + form: 'form[x-data*="tutorForm"]', + modalContent: '.tutor-modal-content', + actionButton: '[data-calendar-action]', + presetButton: '[data-preset]', +} as const; + +const VC_CALENDAR_SELECTORS = { + navigationControls: '[data-vc="controls"]', + dateCell: '[data-vc-date]', + presetButtonsContainer: '.vc-presets [data-preset]', +} as const; + +const TUTOR_CALENDAR_DATA_ATTRS = { + action: 'data-calendar-action', + preset: 'data-preset', + active: 'data-active', + modalCalendar: 'data-tutor-modal-calendar', +} as const; + +const VC_CALENDAR_DATA_ATTRS = { + date: 'data-vc-date', + calendarHidden: 'data-vc-calendar-hidden', +} as const; + +const TUTOR_CALENDAR_VALUES = { + apply: 'apply', + clear: 'clear', + calendarZIndex: '100001', + themeAttrDetect: '[data-tutor-theme]', + calendarClasses: 'vc tutor-vc-calendar', +} as const; + +const TUTOR_DOM_VALUES = { + fixed: 'fixed', + auto: 'auto', +} as const; + +const TUTOR_CALENDAR_EVENTS = { + click: 'click', + focus: 'focus', + pointerDown: 'pointerdown', + mouseDown: 'mousedown', + popstate: 'popstate', + calendarClear: 'tutor-calendar:clear', +} as const; + +const TUTOR_CALENDAR_QUERY_PARAMS = { + startDate: 'start_date', + endDate: 'end_date', + date: 'date', + currentPage: 'current_page', +} as const; + +export function calendar({ options, hidePopover }: { options: Options; hidePopover?: () => void }) { + return { + $el: undefined as HTMLElement | HTMLInputElement | undefined, + $nextTick: null as unknown as (callback: () => void) => void, + calendar: null as Calendar | null, + calendarRootElement: null as HTMLElement | null, + cleanupCalendarRootListeners: null as (() => void) | null, + calendarClearHandler: null as (() => void) | null, + popStateHandler: null as (() => void) | null, + + parseInputValue(inputValue: string): { selectedDates: string[]; selectedTime: string } { + const normalizedValue = inputValue.trim(); + + if (!normalizedValue) { + return { selectedDates: [], selectedTime: '' }; + } + + const [datePart, ...timeParts] = normalizedValue.split(' '); + const selectedDates = datePart ? [datePart] : []; + const selectedTime = timeParts.join(' ').trim(); + + return { selectedDates, selectedTime }; + }, + + syncCalendarWithInputValue(inputValue?: string): void { + if (!this.calendar || !this.calendar.context.inputElement) { + return; + } + + const value = inputValue ?? this.calendar.context.inputElement.value; + const { selectedDates, selectedTime } = this.parseInputValue(value); + + this.calendar.set({ + selectedDates, + selectedTime: selectedTime || undefined, + }); + }, + + setupFormIntegration(): void { + if (!options.inputMode || !(this.$el instanceof HTMLInputElement)) { + return; + } + + const inputElement = this.$el; + const fieldName = inputElement.name; + if (!fieldName) { + return; + } + + const formElement = inputElement.closest(TUTOR_CALENDAR_SELECTORS.form) as HTMLElement | null; + if (!formElement) { + return; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alpineData = window.Alpine?.$data(formElement) as any; + if (!alpineData || typeof alpineData.setValue !== 'function') { + return; + } + + const inputValue = inputElement.value?.trim() ?? ''; + const formValue = alpineData.values?.[fieldName]; + const normalizedFormValue = formValue == null ? '' : String(formValue).trim(); + + // Keep initial input value as the form baseline default when form has no value yet. + if (!normalizedFormValue && inputValue) { + alpineData.setValue(fieldName, inputValue, { + shouldValidate: false, + shouldTouch: false, + shouldDirty: false, + }); + + if (alpineData.fields?.[fieldName]) { + alpineData.fields[fieldName].defaultValue = inputValue; + } + } else if (normalizedFormValue && normalizedFormValue !== inputValue) { + inputElement.value = normalizedFormValue; + this.syncCalendarWithInputValue(normalizedFormValue); + } + + // Watch external form updates (setValue/reset/setValues) and keep calendar state in sync. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component = this as unknown as { $watch?: (path: string, cb: (value: any) => void) => void }; + component.$watch?.(`values['${fieldName}']`, (newValue) => { + const normalizedValue = newValue == null ? '' : String(newValue).trim(); + if (inputElement.value !== normalizedValue) { + inputElement.value = normalizedValue; + } + + this.syncCalendarWithInputValue(normalizedValue); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to integrate calendar with form:', error); + } + }, + + getCalendarRootElement(): HTMLElement | null { + const calendarContext = this.calendar?.context as { mainElement?: HTMLElement } | undefined; + const mainElement = calendarContext?.mainElement ?? null; + if (!mainElement) { + return null; + } + + // In input mode, before first open, mainElement points to the input itself. + if (mainElement instanceof HTMLInputElement) { + return null; + } + + return mainElement; + }, + + setupCalendarRootListeners(): void { + const rootElement = this.getCalendarRootElement(); + if (!rootElement) { + return; + } + + this.cleanupCalendarRootListeners?.(); + + this.calendarRootElement = rootElement; + + const clickHandler = (event: Event) => { + this.handleNavigationClick(event); + this.handlePresetClick(event); + this.handleActionClick(event); + }; + + // Keep modal outside-click handlers from receiving calendar clicks. + const pointerDownHandler = (event: Event) => { + event.stopPropagation(); + }; + + rootElement.addEventListener(TUTOR_CALENDAR_EVENTS.click, clickHandler); + rootElement.addEventListener(TUTOR_CALENDAR_EVENTS.pointerDown, pointerDownHandler); + rootElement.addEventListener(TUTOR_CALENDAR_EVENTS.mouseDown, pointerDownHandler); + + this.cleanupCalendarRootListeners = () => { + rootElement.removeEventListener(TUTOR_CALENDAR_EVENTS.click, clickHandler); + rootElement.removeEventListener(TUTOR_CALENDAR_EVENTS.pointerDown, pointerDownHandler); + rootElement.removeEventListener(TUTOR_CALENDAR_EVENTS.mouseDown, pointerDownHandler); + }; + }, + + integrateWithModalContext(): void { + if (!options.inputMode || !(this.$el instanceof HTMLInputElement)) { + return; + } + + const inputElement = this.$el; + const modalContent = inputElement.closest(TUTOR_CALENDAR_SELECTORS.modalContent) as HTMLElement | null; + if (!modalContent) { + return; + } + + const rootElement = this.getCalendarRootElement(); + if (!rootElement) { + return; + } + + rootElement.setAttribute(TUTOR_CALENDAR_DATA_ATTRS.modalCalendar, 'true'); + if (!modalContent.contains(rootElement)) { + modalContent.appendChild(rootElement); + } + + // Keep viewport-based coordinates after moving into modal DOM. + rootElement.removeAttribute(VC_CALENDAR_DATA_ATTRS.calendarHidden); + rootElement.style.pointerEvents = TUTOR_DOM_VALUES.auto; + rootElement.style.position = TUTOR_DOM_VALUES.fixed; + rootElement.style.zIndex = TUTOR_CALENDAR_VALUES.calendarZIndex; + }, + + init(): void { + if (!this.$el) return; + + this.$nextTick(() => { + const el = this.$el!; + const url = new URL(window.location.href); + + const selectedDates: string[] = []; + let selectedTime = ''; + + // For input mode + if (options.inputMode && (el as HTMLInputElement).value) { + const parsedValue = this.parseInputValue((el as HTMLInputElement).value); + selectedDates.push(...parsedValue.selectedDates); + selectedTime = parsedValue.selectedTime; + } else { + // For filter mode + const startDate = url.searchParams.get(TUTOR_CALENDAR_QUERY_PARAMS.startDate); + const endDate = url.searchParams.get(TUTOR_CALENDAR_QUERY_PARAMS.endDate); + const singleDate = url.searchParams.get(TUTOR_CALENDAR_QUERY_PARAMS.date); + + if (startDate && endDate) { + selectedDates.push(startDate, endDate); + } else if (singleDate) { + selectedDates.push(singleDate); + } + } + + const userOnInit = options.onInit; + const userOnShow = options.onShow; + + this.calendar = new VanillaCalendar(el, { + ...options, + themeAttrDetect: TUTOR_CALENDAR_VALUES.themeAttrDetect, + locale: tutorConfig.local?.replace('_', '-') ?? 'en-US', + enableJumpToSelectedDate: true, + selectedWeekends: [], + displayDatesOutside: false, + ...(selectedDates.length ? { selectedDates } : {}), + ...(selectedTime ? { selectedTime } : {}), + onClickDate: (self, event) => this.handleDateClick(self, event as MouseEvent), + onInit: (self) => { + this.setupCalendarRootListeners(); + this.integrateWithModalContext(); + userOnInit?.(self); + }, + onShow: (self) => { + this.setupCalendarRootListeners(); + this.integrateWithModalContext(); + userOnShow?.(self); + }, + styles: { + calendar: TUTOR_CALENDAR_VALUES.calendarClasses, + }, + layouts: { + multiple: ` + + + + +
+ <#Multiple> +
+ +
+ <#WeekNumbers /> +
+ <#Week /> + <#Dates /> +
+
+
+ <#/Multiple> + <#DateRangeTooltip /> +
+ + <#ControlTime /> + + + `, + }, + }); + + this.calendar.init(); + if (options.inputMode) { + this.syncCalendarWithInputValue((el as HTMLInputElement).value); + this.setupFormIntegration(); + } + this.updateActivePreset(); + + el.addEventListener(TUTOR_CALENDAR_EVENTS.focus, () => this.syncCalendarWithInputValue()); + el.addEventListener(TUTOR_CALENDAR_EVENTS.click, () => this.syncCalendarWithInputValue()); + + this.popStateHandler = () => this.updateActivePreset(); + this.calendarClearHandler = () => this.clear(); + window.addEventListener(TUTOR_CALENDAR_EVENTS.popstate, this.popStateHandler); + window.addEventListener(TUTOR_CALENDAR_EVENTS.calendarClear, this.calendarClearHandler); + }); + }, + + destroy() { + this.cleanupCalendarRootListeners?.(); + this.cleanupCalendarRootListeners = null; + this.calendarRootElement = null; + this.calendar?.destroy(); + this.calendar = null; + + if (this.popStateHandler) { + window.removeEventListener(TUTOR_CALENDAR_EVENTS.popstate, this.popStateHandler); + this.popStateHandler = null; + } + + if (this.calendarClearHandler) { + window.removeEventListener(TUTOR_CALENDAR_EVENTS.calendarClear, this.calendarClearHandler); + this.calendarClearHandler = null; + } + }, + + handleActionClick(e: Event) { + const target = (e.target as HTMLElement).closest(TUTOR_CALENDAR_SELECTORS.actionButton); + if (!target) return; + + const action = target.getAttribute(TUTOR_CALENDAR_DATA_ATTRS.action); + if (action === TUTOR_CALENDAR_VALUES.apply) { + this.applyRange(); + } else if (action === TUTOR_CALENDAR_VALUES.clear) { + this.clear(); + } + }, + + handleNavigationClick(e: Event) { + const target = (e.target as HTMLElement).closest(VC_CALENDAR_SELECTORS.navigationControls); + if (!target) return; + + e.stopPropagation(); + }, + + handlePresetClick(e: Event) { + const target = (e.target as HTMLElement).closest(TUTOR_CALENDAR_SELECTORS.presetButton); + if (!target) return; + + const preset = target.getAttribute(TUTOR_CALENDAR_DATA_ATTRS.preset) as Preset | null; + if (preset) { + this.applyPreset(preset); + } + }, + + handleDateClick(self: Calendar, event: MouseEvent) { + event.stopPropagation(); + if (self.context.inputElement) { + this.handleInputSelection(self); + } else if (self.selectionDatesMode === 'multiple-ranged') { + const date = (event.target as HTMLElement) + .closest(VC_CALENDAR_SELECTORS.dateCell) + ?.getAttribute(VC_CALENDAR_DATA_ATTRS.date); + + if (date && self.context.selectedDates.length === 2 && !self.context.selectedDates[0]) { + this.calendar?.set({ selectedDates: [date, date] }); + } + } else { + this.handleSingleDateSelection(self); + } + }, + + handleInputSelection(self: Calendar) { + const selectedDate = self.context.selectedDates[0] ?? ''; + const selectedTime = self.context.selectedTime ?? ''; + const inputValue = `${selectedDate}${selectedTime ? ` ${selectedTime}` : ''}`.trim(); + + if (self.context.inputElement) { + self.context.inputElement.value = inputValue; + self.context.inputElement.dispatchEvent(new Event('input', { bubbles: true })); + self.context.inputElement.dispatchEvent(new Event('change', { bubbles: true })); + self.context.inputElement.dispatchEvent(new Event('blur', { bubbles: true })); + self.hide(); + } + }, + + applyRange() { + if (!this.calendar || this.calendar.context.selectedDates.length !== 2) return; + + hidePopover?.(); + this.navigateWithParams({ + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: this.calendar.context.selectedDates[0], + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: this.calendar.context.selectedDates[1], + }); + }, + + handleSingleDateSelection(self: Calendar) { + hidePopover?.(); + this.navigateWithParams({ + [TUTOR_CALENDAR_QUERY_PARAMS.date]: self.context.selectedDates[0], + }); + }, + + navigateWithParams(params: Record) { + const url = new URL(window.location.href); + + // Always reset pagination when the date filter changes. + url.searchParams.delete(TUTOR_CALENDAR_QUERY_PARAMS.currentPage); + + // Also strip any additional caller-specified params. + if (Array.isArray((options as Record).clearParams)) { + ((options as Record).clearParams as string[]).forEach((key) => { + url.searchParams.delete(key); + }); + } + + Object.entries(params).forEach(([key, value]) => { + if (value === null) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + }); + + window.location.href = url.toString(); + }, + + clear() { + this.navigateWithParams({ + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, + [TUTOR_CALENDAR_QUERY_PARAMS.date]: null, + }); + }, + + getPresetDates(preset: Preset): string[] { + const today = dayjs().startOf('day'); + + switch (preset) { + case PRESETS.ALL_TIME: + return []; + case PRESETS.YESTERDAY: + return [ + today.subtract(1, 'day').format(DateFormats.yearMonthDay), + today.subtract(1, 'day').format(DateFormats.yearMonthDay), + ]; + case PRESETS.LAST_7: + return [today.subtract(6, 'day').format(DateFormats.yearMonthDay), today.format(DateFormats.yearMonthDay)]; + case PRESETS.LAST_14: + return [today.subtract(13, 'day').format(DateFormats.yearMonthDay), today.format(DateFormats.yearMonthDay)]; + case PRESETS.LAST_30: + return [today.subtract(29, 'day').format(DateFormats.yearMonthDay), today.format(DateFormats.yearMonthDay)]; + case PRESETS.THIS_MONTH: + return [ + today.startOf('month').format(DateFormats.yearMonthDay), + today.endOf('month').format(DateFormats.yearMonthDay), + ]; + case PRESETS.LAST_MONTH: + return [ + today.subtract(1, 'month').startOf('month').format(DateFormats.yearMonthDay), + today.subtract(1, 'month').endOf('month').format(DateFormats.yearMonthDay), + ]; + case PRESETS.LAST_YEAR: + return [ + today.subtract(1, 'year').startOf('year').format(DateFormats.yearMonthDay), + today.subtract(1, 'year').endOf('year').format(DateFormats.yearMonthDay), + ]; + default: + return []; + } + }, + + applyPreset(preset: Preset) { + if (!this.calendar) return; + + const dates = this.getPresetDates(preset); + + if (dates.length) { + this.navigateWithParams({ + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: dates[0], + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: dates[1], + }); + } else { + this.navigateWithParams({ + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, + }); + } + }, + + updateActivePreset() { + if (!this.$el) return; + + const url = new URL(window.location.href); + const startDate = url.searchParams.get(TUTOR_CALENDAR_QUERY_PARAMS.startDate); + const endDate = url.searchParams.get(TUTOR_CALENDAR_QUERY_PARAMS.endDate); + + let activePreset: Preset | '' = ''; + + if (!startDate && !endDate) { + activePreset = PRESETS.ALL_TIME; + } else if (startDate && endDate) { + const presets = (Object.values(PRESETS) as Preset[]).filter((key) => key !== PRESETS.ALL_TIME); + + for (const preset of presets) { + const [start, end] = this.getPresetDates(preset); + if (start === startDate && end === endDate) { + activePreset = preset; + break; + } + } + } + + const buttons = this.$el.querySelectorAll(VC_CALENDAR_SELECTORS.presetButtonsContainer); + buttons.forEach((btn) => { + if (activePreset && btn.getAttribute(TUTOR_CALENDAR_DATA_ATTRS.preset) === activePreset) { + btn.setAttribute(TUTOR_CALENDAR_DATA_ATTRS.active, ''); + } else { + btn.removeAttribute(TUTOR_CALENDAR_DATA_ATTRS.active); + } + }); + }, + }; +} + +export const calendarMeta: AlpineComponentMeta = { + name: 'calendar', + component: calendar, +}; diff --git a/assets/core/ts/components/copy-to-clipboard.ts b/assets/core/ts/components/copy-to-clipboard.ts new file mode 100644 index 0000000000..0750ffd6b4 --- /dev/null +++ b/assets/core/ts/components/copy-to-clipboard.ts @@ -0,0 +1,61 @@ +import { __, sprintf } from '@wordpress/i18n'; + +import { type AlpineComponentMeta } from '@Core/ts/types'; + +const copyToClipboard = () => { + return { + toast: window.TutorCore.toast, + copied: false, + timer: null as number | null, + $el: null as HTMLElement | null, + + async copy(text: string) { + if (!text || !this.$el) { + return; + } + + try { + if (window.isSecureContext && navigator.clipboard) { + await navigator.clipboard.writeText(text); + } else { + throw new Error(__('Clipboard API is not available', 'tutor')); + } + } catch { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + } catch (error) { + this.toast.error( + sprintf( + // translators: %s: error message. + __('Failed to copy to clipboard: %s', 'tutor'), + error, + ), + ); + return; + } + document.body.removeChild(textArea); + } + + this.toast.success(__('Copied to clipboard', 'tutor')); + this.copied = true; + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = window.setTimeout(() => { + this.copied = false; + }, 2000); + }, + }; +}; + +export const copyToClipboardMeta: AlpineComponentMeta = { + name: 'copyToClipboard', + component: copyToClipboard, +}; diff --git a/assets/core/ts/components/file-uploader.ts b/assets/core/ts/components/file-uploader.ts new file mode 100644 index 0000000000..09a8426916 --- /dev/null +++ b/assets/core/ts/components/file-uploader.ts @@ -0,0 +1,582 @@ +import { __, sprintf } from '@wordpress/i18n'; + +import { type FormControlMethods, type SetValueOptions, type ValidationRules } from '@Core/ts/components/form'; +import { type WPMedia, type WPMediaType } from '@Core/ts/services/WPMedia'; +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { formatBytes } from '@Core/ts/utils/format'; + +import { tutorConfig } from '@TutorShared/config/config'; + +type FileUploaderVariant = 'file-uploader' | 'image-uploader'; + +export interface FileUploaderProps { + multiple?: boolean; + maxFiles?: number; + accept?: string; + maxSize?: number; // in bytes + onFileSelect?: (files: (File | WPMedia | string)[]) => void; + onError?: (error: string) => void; + onFileRemove?: (file: File | WPMedia | string) => void; + disabled?: boolean; + variant?: FileUploaderVariant; + value?: File | WPMedia | string | (File | WPMedia | string)[] | null; + name?: string; + required?: boolean | string; + imagePreviewPlaceholder?: string; + useWPMedia?: boolean; + wpMediaTitle?: string; + wpMediaButtonText?: string; + wpMediaLibraryType?: WPMediaType; +} + +const defaultProps = { + multiple: false, + maxFiles: undefined, + accept: '.pdf,.doc,.docx,.jpg,.jpeg,.png', + maxSize: Number(tutorConfig.max_upload_size), + variant: 'file-uploader', + value: [], + name: '', + required: false, + imagePreviewPlaceholder: '', + useWPMedia: false, + wpMediaTitle: '', + wpMediaButtonText: '', + wpMediaLibraryType: undefined, +} satisfies FileUploaderProps; + +const normalizeValue = (value: FileUploaderProps['value']): (File | WPMedia | string)[] => { + if (!value) return []; + if (Array.isArray(value)) return value; + return [value]; +}; + +function getInitialImagePreview( + variant: FileUploaderVariant | undefined, + value: FileUploaderProps['value'], + placeholder: string, +): string { + if (variant !== 'image-uploader') { + return placeholder || ''; + } + + if (typeof value === 'string' && value) { + return value; + } + + if (value && typeof value === 'object' && !Array.isArray(value) && 'url' in value) { + return (value as WPMedia).url; + } + + if (Array.isArray(value) && value.length > 0) { + const first = value[0]; + if (typeof first === 'string') { + return first; + } + if (first && typeof first === 'object' && 'url' in first) { + return (first as WPMedia).url; + } + } + + return placeholder || ''; +} + +export const fileUploader = (props: FileUploaderProps = defaultProps) => ({ + isDragOver: false, + isDisabled: props.disabled, + multiple: !!props.multiple, + maxFiles: props.maxFiles, + accept: props.accept, + maxSize: props.maxSize || 52428800, + variant: props.variant, + imagePreview: getInitialImagePreview(props.variant, props.value, props.imagePreviewPlaceholder || ''), + selectedFiles: normalizeValue(props.value), + name: props.name || '', + required: props.required || false, + useWPMedia: props.useWPMedia || false, + wpMediaTitle: props.wpMediaTitle || __('Select File', 'tutor'), + wpMediaButtonText: props.wpMediaButtonText || __('Use this file', 'tutor'), + wpMediaLibraryType: props.wpMediaLibraryType, + formatBytes, + $refs: {} as { fileInput: HTMLInputElement }, + + init() { + this.$refs.fileInput.addEventListener('change', (event: Event) => this.handleFileSelect(event)); + this.setupFormIntegration(); + }, + + destroy() { + this.$refs.fileInput.removeEventListener('change', (event: Event) => this.handleFileSelect(event)); + }, + + handleDragOver(event: DragEvent) { + event.preventDefault(); + if (!this.isDisabled) { + this.isDragOver = true; + } + }, + + handleDragLeave(event: DragEvent) { + event.preventDefault(); + this.isDragOver = false; + }, + + handleDrop(event: DragEvent) { + event.preventDefault(); + this.isDragOver = false; + + if (this.isDisabled) return; + + const files = Array.from(event.dataTransfer?.files || []); + this.processFiles(files); + }, + + handleFileSelect(event: Event) { + event.preventDefault(); + const input = event.target as HTMLInputElement; + const files = Array.from(input.files || []); + this.processFiles(files); + + try { + input.value = ''; + } catch { + // InvalidStateError can occur in certain browser states, safe to ignore + } + }, + + openFileDialog() { + if (this.isDisabled) { + return; + } + + if (this.useWPMedia) { + this.openWPMediaLibrary(); + } else { + this.$refs.fileInput.click(); + } + }, + + openWPMediaLibrary() { + const wpMediaService = window.TutorCore?.wpMedia; + + if (!wpMediaService?.isAvailable()) { + this.showError(__('WordPress media library is not available', 'tutor')); + return; + } + + const existingIds = this.selectedFiles + .filter((file): file is WPMedia => typeof file === 'object' && 'id' in file && typeof file.id === 'number') + .map((file) => file.id); + + wpMediaService.open( + { + title: this.wpMediaTitle, + button: { text: this.wpMediaButtonText }, + multiple: this.multiple, + library: this.wpMediaLibraryType ? { type: this.wpMediaLibraryType } : undefined, + maxFileSize: this.maxSize, + maxFiles: this.maxFiles, + }, + (files: WPMedia[]) => { + this.processFiles(files); + }, + existingIds, + ); + }, + + processFiles(files: (File | WPMedia | string)[]) { + if (!files.length) { + return; + } + + const validFiles: (File | WPMedia | string)[] = []; + + for (const file of files) { + if (typeof file === 'string') { + validFiles.push(file); + continue; + } + + if (this.isWPMedia(file)) { + validFiles.push(file); + continue; + } + + if (file.size > this.maxSize) { + this.showError( + sprintf( + // translators: %1$s is the file name, %2$s is the maximum allowed size + __('File %1$s is too large. Maximum allowed size is %2$s.', 'tutor'), + file.name, + formatBytes(this.maxSize), + ), + ); + continue; + } + + if (this.accept && !this.isFileTypeAccepted(file)) { + this.showError( + sprintf( + // translators: %s is the file type + __('File type %s is not allowed', 'tutor'), + file.type, + ), + ); + continue; + } + + validFiles.push(file); + } + + if (validFiles.length > 0) { + if (!this.isFileListChanged(this.selectedFiles, validFiles)) { + return; + } + + if (this.multiple) { + const mergedFiles = this.mergeFileLists(this.selectedFiles, validFiles); + const totalFileSize = mergedFiles.reduce((total, current) => { + if (typeof current === 'string') { + return 0; + } + return total + Math.round(Number(current?.size) || 0); + }, 0); + + if (this.maxFiles && mergedFiles.length > this.maxFiles) { + this.showError( + sprintf( + // translators: %d is the maximum number of files allowed. + __('Cannot select more than %d files', 'tutor'), + this.maxFiles, + ), + ); + return; + } + + if (totalFileSize > this.maxSize) { + this.showError( + sprintf( + // translators: %1$s is the maximum allowed size. + __('Maximum allowed size is %1$s.', 'tutor'), + formatBytes(this.maxSize), + ), + ); + return; + } + + this.selectedFiles = mergedFiles; + } else { + this.selectedFiles = [validFiles[0]]; + } + + this.updateFormValue(); + this.syncFileInput(); + + if (this.variant === 'image-uploader' && this.selectedFiles.length > 0) { + const firstFile = this.selectedFiles[0]; + if (this.isWPMedia(firstFile) && firstFile.url) { + this.imagePreview = firstFile.url; + } else if (firstFile instanceof File && firstFile.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = (e) => { + this.imagePreview = e.target?.result as string; + }; + reader.readAsDataURL(firstFile); + } + } + + if (props.onFileSelect) { + props.onFileSelect(this.selectedFiles); + } + } + }, + + removeFile(index?: number) { + if (props.onFileRemove && typeof index === 'number') { + props.onFileRemove(this.selectedFiles[index]); + } + + if (this.multiple && typeof index === 'number') { + this.selectedFiles = this.selectedFiles.filter((_, i) => i !== index); + } else { + this.selectedFiles = []; + } + + if (this.selectedFiles.length === 0) { + this.imagePreview = ''; + } + + if (this.$refs.fileInput) { + try { + this.$refs.fileInput.value = ''; + } catch { + // Safe to ignore + } + + this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true })); + this.$refs.fileInput.dispatchEvent(new Event('input', { bubbles: true })); + } + + this.updateFormValue(); + this.syncFileInput(); + + if (props.onFileSelect) { + props.onFileSelect(this.selectedFiles); + } + }, + + mergeFileLists( + previousList: (File | WPMedia | string)[] | null, + newList: (File | WPMedia | string)[] | null, + ): (File | WPMedia | string)[] { + const seen = new Set(); + const result: (File | WPMedia | string)[] = []; + + const add = (files: (File | WPMedia | string)[] | null) => { + if (!files) return; + for (const file of Array.from(files)) { + const key = this.getFileKey(file); + if (seen.has(key)) continue; + seen.add(key); + result.push(file); + } + }; + + add(previousList); + add(newList); + return result; + }, + + isFileListChanged( + previousList: (File | WPMedia | string)[] | null, + nextList: (File | WPMedia | string)[] | null, + ): boolean { + if (previousList === nextList) { + return false; + } + if (!previousList || !nextList) { + return true; + } + if (previousList.length !== nextList.length) { + return true; + } + + const prevKeys = new Set(Array.from(previousList, (file) => this.getFileKey(file))); + return nextList.some((file) => !prevKeys.has(this.getFileKey(file))); + }, + + getFileKey(file: File | WPMedia | string): string { + if (typeof file === 'string') { + return file; + } + if (this.isWPMedia(file)) { + return `wp:${file.id}`; + } + return `${file.name}|${file.size}|${file.lastModified}|${file.type}`; + }, + + isWPMedia(file: File | WPMedia | string): file is WPMedia { + return typeof file === 'object' && 'id' in file && typeof (file as WPMedia).id === 'number'; + }, + + isFileTypeAccepted(file: File): boolean { + if (!this.accept) return true; + + const acceptedTypes = this.accept.split(',').map((type) => type.trim().toLowerCase()); + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); + const fileMimeType = file.type.toLowerCase(); + + return acceptedTypes.some((acceptedType) => { + if (acceptedType.startsWith('.')) { + return acceptedType === fileExtension; + } + return acceptedType === fileMimeType || acceptedType === fileMimeType.split('/')[0] + '/*'; + }); + }, + + getFormElement(): HTMLElement | null { + const $el = (this as unknown as { $el: HTMLElement }).$el; + return $el.closest('form[x-data*="tutorForm"], form[x-data*="form("]'); + }, + setupFormIntegration() { + if (!this.name) { + return; + } + + const formElement = this.getFormElement(); + if (!formElement) { + return; + } + + try { + const alpine = window.Alpine; + const alpineData = alpine?.$data(formElement) as FormControlMethods & { values: Record }; + + if (!alpineData || typeof alpineData.register !== 'function') { + return; + } + + this.registerFormField(alpineData); + this.syncInitialValue(alpineData); + this.watchFormChanges(); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + sprintf( + // translators: %s is the error message + __('Failed to integrate with form: %s', 'tutor'), + err, + ), + ); + } + }, + + registerFormField(alpineData: FormControlMethods & { values: Record }) { + const rules: ValidationRules = { + numberOnly: false, + }; + + if (this.required) { + rules.required = this.required; + } + + alpineData.register(this.name, rules); + }, + + syncInitialValue(alpineData: FormControlMethods & { values: Record }) { + const formValue = alpineData.values?.[this.name]; + const hasFormValue = formValue !== undefined && formValue !== null && formValue !== ''; + + if (hasFormValue) { + this.selectedFiles = Array.isArray(formValue) ? formValue : [formValue]; + this.syncFileInput(); + return; + } + + this.updateFormValue({ + shouldValidate: false, + shouldTouch: false, + shouldDirty: false, + }); + }, + + watchFormChanges() { + const component = this as unknown as { $watch: (path: string, cb: (val: unknown) => void) => void }; + + if (!component.$watch) { + return; + } + + component.$watch(`values.${this.name}`, (newVal: unknown) => { + const normalized = Array.isArray(newVal) ? newVal : ((newVal ? [newVal] : []) as (File | WPMedia | string)[]); + + if (!this.isFileListChanged(this.selectedFiles, normalized)) { + return; + } + + this.selectedFiles = normalized; + this.syncFileInput(); + + if (this.variant === 'image-uploader') { + this.updateImagePreview(); + } + }); + }, + + updateImagePreview() { + if (this.selectedFiles.length === 0) { + this.imagePreview = ''; + return; + } + + const firstFile = this.selectedFiles[0]; + + if (typeof firstFile === 'string') { + this.imagePreview = firstFile; + return; + } + + // Handle WPMedia objects + if (this.isWPMedia(firstFile) && firstFile.url) { + this.imagePreview = firstFile.url; + return; + } + + if (firstFile instanceof File && firstFile.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = (e) => { + this.imagePreview = e.target?.result as string; + }; + reader.readAsDataURL(firstFile); + } + }, + + updateFormValue(options?: SetValueOptions) { + options = options ?? { + shouldValidate: true, + shouldTouch: true, + shouldDirty: true, + }; + + if (!this.name) { + return; + } + + const formElement = this.getFormElement(); + if (!formElement) { + return; + } + + try { + const alpineData = window.Alpine?.$data(formElement) as FormControlMethods & { values: Record }; + + if (alpineData && typeof alpineData.setValue === 'function') { + const value = this.multiple ? this.selectedFiles : this.selectedFiles[0] || null; + alpineData.setValue(this.name, value, options); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + sprintf( + // translators: %s is the error message + __('Failed to update form value: %s', 'tutor'), + err, + ), + ); + } + }, + + syncFileInput() { + if (this.$refs.fileInput) { + const dt = new DataTransfer(); + this.selectedFiles.forEach((file) => { + if (file instanceof File) { + dt.items.add(file); + } + }); + + try { + this.$refs.fileInput.files = dt.files; + } catch { + // Safe to ignore + } + + // Dispatch events to notify listeners (including Alpine's x-model if any) + this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true })); + this.$refs.fileInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, + + showError(message: string) { + if (props.onError) { + props.onError(message); + } else { + window.TutorCore.toast.error(message); + } + }, +}); + +export const fileUploaderMeta: AlpineComponentMeta = { + name: 'fileUploader', + component: fileUploader, +}; diff --git a/assets/core/ts/components/form.ts b/assets/core/ts/components/form.ts new file mode 100644 index 0000000000..987747aa2b --- /dev/null +++ b/assets/core/ts/components/form.ts @@ -0,0 +1,985 @@ +import { __, sprintf } from '@wordpress/i18n'; +import dayjs from 'dayjs'; + +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { parseNumberOnly } from '@Core/ts/utils/number'; + +interface FormControlConfig { + mode?: 'onChange' | 'onBlur' | 'onSubmit'; + shouldFocusError?: boolean; + shouldScrollToError?: boolean; + defaultValues?: Record; +} + +interface FieldConfig { + name: string; + rules?: ValidationRules; + defaultValue?: unknown; + ref?: HTMLInputElement; + type?: string; + isCheckboxArray?: boolean; +} + +export interface ValidationRules { + required?: boolean | string; + minLength?: number | { value: number; message: string }; + maxLength?: number | { value: number; message: string }; + min?: number | { value: number; message: string }; + max?: number | { value: number; message: string }; + pattern?: RegExp | { value: RegExp; message: string }; + validTime?: boolean | string | { message: string }; + numberOnly: boolean | { allowNegative?: boolean; whole?: boolean }; + validate?: (value: unknown) => boolean | string | Promise; +} + +export interface FieldError { + type: string; + message: string; +} + +export interface SetValueOptions { + shouldValidate?: boolean; + shouldTouch?: boolean; + shouldDirty?: boolean; +} + +interface FocusOptions { + shouldSelect?: boolean; +} + +export interface FormState { + values: Record; + errors: Record; + touchedFields: Record; + dirtyFields: Record; + isValid: boolean; + isDirty: boolean; + isSubmitting: boolean; + isValidating: boolean; +} + +export interface FormControlMethods { + register(name: string, rules?: ValidationRules): Record; + watch(name?: string): unknown; + setValue(name: string, value: unknown, options?: SetValueOptions): void; + getValue(name: string): unknown; + setFocus(name: string, options?: FocusOptions): void; + trigger(name?: string | string[]): Promise; + clearErrors(name?: string | string[]): void; + setError(name: string, error: FieldError): void; + reset(values?: Record): void; + handleSubmit( + onValid: (data: Record) => void | Promise, + onInvalid?: (errors: Record) => void | Promise, + ): (event: Event) => void; + getFormState(): FormState; + isFieldVisible(element: HTMLElement): boolean; +} + +const ValidationHelpers = { + validateRequired(name: string, value: unknown, rule?: boolean | string): FieldError | null { + if (!rule) return null; + + const message = typeof rule === 'string' ? rule : __('This field is required', 'tutor'); + const isEmpty = !value || (typeof value === 'string' && value.trim() === ''); + + return isEmpty ? { type: 'required', message } : null; + }, + + validateMinLength(value: string, rule: number | { value: number; message: string }): FieldError | null { + if (!value) return null; + + const minLength = typeof rule === 'number' ? rule : rule.value; + const message = + typeof rule === 'object' + ? rule.message + : sprintf( + // translators: %s is the minimum length + __('Minimum length is %s', 'tutor'), + minLength, + ); + + return value.length < minLength ? { type: 'minLength', message } : null; + }, + + validateMaxLength(value: string, rule: number | { value: number; message: string }): FieldError | null { + if (!value) return null; + + const maxLength = typeof rule === 'number' ? rule : rule.value; + const message = + typeof rule === 'object' + ? rule.message + : sprintf( + // translators: %s is the maximum length + __('Maximum length is %s', 'tutor'), + maxLength, + ); + + return value.length > maxLength ? { type: 'maxLength', message } : null; + }, + + validateMin(value: number, rule: number | { value: number; message: string }): FieldError | null { + const min = typeof rule === 'number' ? rule : rule.value; + const message = + typeof rule === 'object' + ? rule.message + : sprintf( + // translators: %s is the minimum value + __('Minimum value is %s', 'tutor'), + min, + ); + + return value < min ? { type: 'min', message } : null; + }, + + validateMax(value: number, rule: number | { value: number; message: string }): FieldError | null { + const max = typeof rule === 'number' ? rule : rule.value; + const message = + typeof rule === 'object' + ? rule.message + : sprintf( + // translators: %s is the maximum value + __('Maximum value is %s', 'tutor'), + max, + ); + + return value > max ? { type: 'max', message } : null; + }, + + validatePattern(value: string, rule: RegExp | { value: RegExp; message: string }): FieldError | null { + const pattern = rule instanceof RegExp ? rule : rule.value; + const message = typeof rule === 'object' && 'message' in rule ? rule.message : __('Invalid format', 'tutor'); + + return !pattern.test(value) ? { type: 'pattern', message } : null; + }, + + isValidTimeValue(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + const trimmed = value.trim(); + if (!trimmed) { + return true; + } + + // AM/PM marker is required for this rule. + if (!/\b(?:AM|PM)\b/i.test(trimmed)) { + return false; + } + + const parsed12Hour = dayjs(trimmed, 'hh:mm A', true); + return parsed12Hour.isValid(); + }, + + validateValidTime(value: unknown, rule?: boolean | string | { message: string }): FieldError | null { + if (!rule) return null; + + const stringValue = String(value ?? '').trim(); + if (!stringValue) return null; + + const message = + typeof rule === 'string' + ? rule + : typeof rule === 'object' && 'message' in rule + ? rule.message + : __('Invalid time entered!', 'tutor'); + + return this.isValidTimeValue(value) ? null : { type: 'validTime', message }; + }, + + async validateCustom( + value: unknown, + validate: (value: unknown) => boolean | string | Promise, + ): Promise { + try { + const result = await validate(value); + if (result === true) return null; + + const message = typeof result === 'string' ? result : __('Validation failed', 'tutor'); + return { type: 'validate', message }; + } catch { + return { type: 'validate', message: __('Validation error', 'tutor') }; + } + }, +}; + +async function validateFieldValue(name: string, value: unknown, rules?: ValidationRules): Promise { + if (!rules) return null; + + // Required validation + const requiredError = ValidationHelpers.validateRequired(name, value, rules.required); + if (requiredError) return requiredError; + + const stringValue = String(value || ''); + const numericValue = typeof value === 'number' ? value : parseFloat(stringValue); + + // String length validations + if (rules.minLength) { + const error = ValidationHelpers.validateMinLength(stringValue, rules.minLength); + if (error) return error; + } + + if (rules.maxLength) { + const error = ValidationHelpers.validateMaxLength(stringValue, rules.maxLength); + if (error) return error; + } + + // Numeric validations + if (rules.min && !isNaN(numericValue)) { + const error = ValidationHelpers.validateMin(numericValue, rules.min); + if (error) return error; + } + + if (rules.max && !isNaN(numericValue)) { + const error = ValidationHelpers.validateMax(numericValue, rules.max); + if (error) return error; + } + + // Pattern validation + if (rules.pattern && stringValue) { + const error = ValidationHelpers.validatePattern(stringValue, rules.pattern); + if (error) return error; + } + + // Time validation + if (rules.validTime) { + const error = ValidationHelpers.validateValidTime(value, rules.validTime); + if (error) return error; + } + + // Custom validation + if (rules.validate) { + const error = await ValidationHelpers.validateCustom(value, rules.validate); + if (error) return error; + } + + return null; +} + +const DOMUtils = { + isElementVisible(element: HTMLElement): boolean { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + parseFloat(style.opacity) > 0 && + rect.width > 0 && + rect.height > 0 + ); + }, + + focusElement(element: HTMLElement, options: { shouldSelect?: boolean; shouldScroll?: boolean }): void { + const { shouldSelect = false, shouldScroll = true } = options; + + if (!this.isElementVisible(element)) return; + + element.focus(); + + if (shouldSelect && element instanceof HTMLInputElement && element.select) { + element.select(); + } + + if (shouldScroll) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, + + updateElementValue(element: HTMLElement, value: unknown): void { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + element.value = String(value ?? ''); + } + }, + + setAriaInvalid(element: HTMLElement, isInvalid: boolean): void { + if (isInvalid) { + element.setAttribute('aria-invalid', 'true'); + } else { + element.removeAttribute('aria-invalid'); + } + }, + + getFieldElement(form: HTMLElement, fieldName: string): HTMLElement | null { + return form.querySelector(`[name="${fieldName}"]`); + }, +}; + +const FormDataUtils = { + convertToFormData(values: Record, method: string = 'POST'): FormData { + const formData = new FormData(); + + Object.entries(values).forEach(([key, value]) => { + if (Array.isArray(value)) { + this.appendArrayToFormData(formData, key, value); + } else { + this.appendValueToFormData(formData, key, value); + } + }); + + formData.append('_method', method.toUpperCase()); + return formData; + }, + + serializeParams(params: Record): Record { + return Object.entries(params).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: this.serializeValue(value), + }), + {}, + ); + }, + + appendArrayToFormData(formData: FormData, key: string, array: unknown[]): void { + array.forEach((item, index) => { + const value = this.convertToFormDataValue(item); + formData.append(`${key}[${index}]`, value); + }); + }, + + appendValueToFormData(formData: FormData, key: string, value: unknown): void { + const formDataValue = this.convertToFormDataValue(value); + formData.append(key, formDataValue); + }, + + convertToFormDataValue(value: unknown): string | Blob { + if (value instanceof File || value instanceof Blob) return value; + if (typeof value === 'string') return value; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (typeof value === 'object' && value !== null) return JSON.stringify(value); + return String(value); + }, + + serializeValue(value: unknown): string | unknown { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return value; + }, +}; + +const DEFAULT_CONFIG: FormControlConfig = { + mode: 'onBlur', + shouldFocusError: true, + shouldScrollToError: true, +}; + +interface AlpineComponent { + $el: HTMLElement; + $refs: Record void } }>; + $data: (el: HTMLElement) => Record; +} + +export const form = (config: FormControlConfig & { id?: string } = {}) => { + const { id: formId, defaultValues = {}, ...formConfig } = config; + const mergedConfig = { ...DEFAULT_CONFIG, ...formConfig }; + + const formInstance = { + config: mergedConfig, + formId, + + fields: {} as Record, + values: { ...defaultValues }, + errors: {} as Record, + touchedFields: {} as Record, + dirtyFields: {} as Record, + isValid: true, + isSubmitting: false, + isValidating: false, + isResetting: false, + cleanup: undefined as (() => void) | undefined, + lastIsDirty: false, + + init(): void { + this.isValid = true; + this.isSubmitting = false; + this.isValidating = false; + this.lastIsDirty = false; + + if (this.formId) { + document.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.FORM_REGISTER, { + detail: { id: this.formId, instance: this as unknown as FormControlMethods }, + }), + ); + } + + this.setupFormListeners(); + this.dispatchStateChange(); // Initial dispatch + }, + + destroy(): void { + this.cleanup?.(); + this.clearAllState(); + }, + + dispatchStateChange(): void { + if (!this.formId) { + return; + } + + const isDirty = Object.values(this.dirtyFields).some(Boolean); + + if (isDirty === this.lastIsDirty) { + return; + } + + this.lastIsDirty = isDirty; + + document.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.FORM_STATE_CHANGE, { + detail: { id: this.formId, isDirty }, + }), + ); + }, + + register(name: string, rules?: ValidationRules): Record { + const component = this as unknown as AlpineComponent; + const element = component.$el as HTMLInputElement; + + const type = element?.type ?? 'text'; + const isCheckbox = type === 'checkbox'; + const isFile = type === 'file'; + const existingField = this.fields[name]; + const isSameElement = existingField?.ref === element; + + // Treat as checkbox group only when a different checkbox with the same name already exists; avoids upgrading a single checkbox to an array during re-registration (e.g., after DOM reorder). + const isCheckboxArray = isCheckbox && existingField?.type === 'checkbox' && !isSameElement; + + let defaultValue: unknown; + + if (isCheckboxArray) { + // Ensure array type for checkbox groups and include any existing checked value + const currentValue = this.values[name]; + const aggregatedValues: string[] = Array.isArray(currentValue) ? [...currentValue] : []; + + const existingCheckbox = existingField?.ref; + const existingChecked = + existingCheckbox?.checked ?? + (typeof existingField?.defaultValue === 'boolean' ? existingField.defaultValue : false); + + if (existingChecked) { + const existingValue = existingCheckbox?.value ?? 'on'; + if (!aggregatedValues.includes(existingValue)) { + aggregatedValues.push(existingValue); + } + } + + if (element?.checked) { + const currentValueToAdd = element.value ?? 'on'; + if (!aggregatedValues.includes(currentValueToAdd)) { + aggregatedValues.push(currentValueToAdd); + } + } + + defaultValue = aggregatedValues; + } else if (isCheckbox) { + defaultValue = this.values[name] ?? element?.checked ?? false; + } else { + defaultValue = this.values[name] ?? ''; + } + + this.fields[name] = { + name, + rules, + defaultValue, + ref: element, + type, + isCheckboxArray, + }; + + // Force upgrade value to array if a collision is detected + if (isCheckboxArray && !Array.isArray(this.values[name])) { + this.values[name] = defaultValue; + } else { + this.values[name] ??= defaultValue; + } + + const valueExpression = isCheckboxArray + ? '$event.target.value' + : isCheckbox + ? '$event.target.checked' + : '$event.target.value'; + + const bindings: Record = { + name, + 'x-ref': name, + ':aria-invalid': `!!errors["${name}"]`, + ':class': `{ + 'tutor-input-error': errors["${name}"], + 'tutor-input-touched': touchedFields["${name}"], + 'tutor-input-dirty': dirtyFields["${name}"] + }`, + }; + + if (!isFile) { + bindings['x-model'] = `values["${name}"]`; + + bindings['@input'] = `handleFieldInput('${name}', ${valueExpression}, $event.target)`; + + bindings['@blur'] = `handleFieldBlur('${name}', ${valueExpression})`; + } + + return bindings; + }, + + handleCheckboxArrayInput(name: string, element?: HTMLInputElement): void { + const field = this.fields[name]; + const currentValue = this.values[name] as string[]; + const valueArray = Array.isArray(currentValue) ? [...currentValue] : []; + + // Use the passed element (from $event.target) or try to get from $refs + const checkbox = element || ((this as unknown as AlpineComponent).$refs[name] as HTMLInputElement); + + if (!checkbox) return; + + const checkboxValue = checkbox.value; + const isChecked = checkbox.checked; + + let newValue: string[]; + if (isChecked) { + newValue = valueArray.includes(checkboxValue) ? valueArray : [...valueArray, checkboxValue]; + } else { + newValue = valueArray.filter((v) => v !== checkboxValue); + } + + const defaultArray = Array.isArray(field.defaultValue) ? field.defaultValue : []; + // Sort to compare content regardless of order + const isActuallyChanged = JSON.stringify(newValue.sort()) !== JSON.stringify(defaultArray.sort()); + + this.values[name] = newValue; + this.dirtyFields[name] = isActuallyChanged; + + const shouldValidate = this.config.mode === 'onChange' || this.touchedFields[name]; + + if (shouldValidate) { + this.validateField(name, newValue); + } else { + this.dispatchStateChange(); + } + }, + + handleFieldInput(name: string, value: unknown, element?: HTMLInputElement): void { + const field = this.fields[name]; + + if (field?.isCheckboxArray) { + this.handleCheckboxArrayInput(name, element); + return; + } + + // Original logic for non-checkbox-array fields + const isNumber = field?.rules?.numberOnly; + const allowNegative = typeof isNumber === 'object' && isNumber.allowNegative; + const whole = typeof isNumber === 'object' && isNumber.whole; + const parsedValue = isNumber ? parseNumberOnly(value as string, allowNegative, whole) : value; + + // Only mark as dirty if the value is different from the baseline + const isActuallyChanged = String(parsedValue) !== String(field?.defaultValue ?? ''); + + this.values[name] = parsedValue; + this.dirtyFields[name] = isActuallyChanged; + this.updateFieldRef(name); + + if (isNumber) { + const component = this as unknown as AlpineComponent; + const refs = component.$refs; + if (refs[name]?._x_model) { + refs[name]._x_model?.set(parsedValue); + } + } + + const shouldValidate = this.config.mode === 'onChange'; + + if (shouldValidate) { + this.validateField(name, isNumber ? parsedValue : value); + } else { + this.dispatchStateChange(); + } + }, + + handleFieldBlur(name: string, value: unknown): void { + this.touchedFields[name] = true; + this.updateFieldRef(name); + + const shouldValidate = this.config.mode === 'onBlur' || this.config.mode === 'onChange'; + + if (shouldValidate) { + this.validateField(name, value); + } else { + this.dispatchStateChange(); + } + }, + + watch(name?: string): unknown { + return name ? this.values[name] : { ...this.values }; + }, + + setValue(name: string, value: unknown, options: SetValueOptions = {}): void { + const { shouldValidate = false, shouldTouch = false, shouldDirty = true } = options; + + this.values[name] = value; + + if (shouldTouch) this.touchedFields[name] = true; + if (shouldDirty) { + const field = this.fields[name]; + // Handle array comparison for checkbox arrays + if (Array.isArray(value) && Array.isArray(field?.defaultValue)) { + this.dirtyFields[name] = JSON.stringify(value.sort()) !== JSON.stringify(field.defaultValue.sort()); + } else { + this.dirtyFields[name] = String(value) !== String(field?.defaultValue ?? ''); + } + } + + const fieldElement = this.fields[name]?.ref; + if (fieldElement && this.fields[name].type !== 'file') { + // For checkbox arrays, we need to update all checkboxes with this name + if (Array.isArray(value) && fieldElement.type === 'checkbox') { + this.syncCheckboxArray(name, value as string[]); + } else { + DOMUtils.updateElementValue(fieldElement, value); + } + } + + if (shouldValidate) { + this.validateField(name, value); + } else { + this.dispatchStateChange(); + } + }, + + getValue(name: string): unknown { + return this.values[name]; + }, + + setFocus(name: string, options: FocusOptions = {}): void { + const field = this.fields[name]; + const fieldElement = field?.ref; + + if (fieldElement && DOMUtils.isElementVisible(fieldElement)) { + DOMUtils.focusElement(fieldElement, { + shouldSelect: options.shouldSelect, + shouldScroll: this.config.shouldScrollToError, + }); + } + }, + + async trigger(name?: string | string[]): Promise { + this.isValidating = true; + + try { + if (typeof name === 'string') { + return await this.validateSingleField(name); + } + + if (Array.isArray(name)) { + return await this.validateMultipleFields(name); + } + + return await this.validateAllFields(); + } finally { + this.isValidating = false; + this.dispatchStateChange(); + } + }, + + async validateField(name: string, value: unknown): Promise { + const fieldConfig = this.fields[name]; + const error = await validateFieldValue(name, value, fieldConfig?.rules); + + if (error) { + this.errors[name] = error; + } else { + delete this.errors[name]; + } + + this.updateAriaInvalidState(name); + this.updateFormValidState(); + this.dispatchStateChange(); + + return !error; + }, + + async validateSingleField(name: string): Promise { + this.touchedFields[name] = true; + const value = this.values[name]; + return await this.validateField(name, value); + }, + + async validateMultipleFields(names: string[]): Promise { + let isValid = true; + + for (const fieldName of names) { + this.touchedFields[fieldName] = true; + const value = this.values[fieldName]; + const fieldValid = await this.validateField(fieldName, value); + if (!fieldValid) isValid = false; + } + + return isValid; + }, + + async validateAllFields(): Promise { + let isValid = true; + + for (const name of Object.keys(this.fields)) { + const value = this.values[name]; + const fieldValid = await this.validateField(name, value); + if (!fieldValid) isValid = false; + } + + return isValid; + }, + + clearErrors(name?: string | string[]): void { + if (typeof name === 'string') { + this.clearSingleError(name); + } else if (Array.isArray(name)) { + name.forEach((fieldName) => this.clearSingleError(fieldName)); + } else { + Object.keys(this.fields).forEach((fieldName) => this.clearSingleError(fieldName)); + } + + this.updateFormValidState(); + this.dispatchStateChange(); + }, + + setError(name: string, error: FieldError): void { + this.errors[name] = error; + this.updateAriaInvalidState(name); + this.updateFormValidState(); + this.dispatchStateChange(); + }, + + reset(values?: Record): void { + this.isResetting = true; + + if (values) { + // Update reactive object props instead of replacing it to maintain bindings + Object.keys(this.values).forEach((key) => delete this.values[key]); + Object.assign(this.values, values); + + // Update baseline default values for all fields provided + Object.entries(values).forEach(([name, value]) => { + if (this.fields[name]) { + this.fields[name].defaultValue = value; + } + }); + } else { + const defaultValues = Object.keys(this.fields).reduce( + (acc, name) => { + acc[name] = this.fields[name].defaultValue; + return acc; + }, + {} as Record, + ); + Object.keys(this.values).forEach((key) => delete this.values[key]); + Object.assign(this.values, defaultValues); + } + + this.syncDOMWithState(); + this.errors = {}; + this.touchedFields = {}; + this.dirtyFields = {}; + this.isValid = true; + this.isSubmitting = false; + this.isValidating = false; + this.dispatchStateChange(); + + // Dispatch custom event for components like WPEditor to listen to + if (this.formId) { + window.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.FORM_RESET, { + detail: { + formId: this.formId, + defaultValues: + values || + Object.keys(this.fields).reduce( + (acc, name) => { + acc[name] = this.fields[name].defaultValue; + return acc; + }, + {} as Record, + ), + }, + }), + ); + } + + setTimeout(() => { + this.isResetting = false; + }, 0); + }, + + handleSubmit( + onValid: (data: Record) => void, + onInvalid?: (errors: Record) => void, + ): (event: Event) => void { + return async (event: Event) => { + event.preventDefault(); + this.isSubmitting = true; + + try { + const isValid = await this.validateAllFields(); + + if (isValid) { + await onValid({ ...this.values }); + } else { + await onInvalid?.({ ...this.errors }); + + if (this.config.shouldFocusError) { + this.focusFirstError(); + } + } + } finally { + this.isSubmitting = false; + this.dispatchStateChange(); + } + }; + }, + + getFormState(): FormState { + return { + values: { ...this.values }, + errors: { ...this.errors }, + touchedFields: { ...this.touchedFields }, + dirtyFields: { ...this.dirtyFields }, + isValid: this.isValid, + isDirty: Object.values(this.dirtyFields).some(Boolean), + isSubmitting: this.isSubmitting, + isValidating: this.isValidating, + }; + }, + + isFieldVisible(element: HTMLElement): boolean { + return DOMUtils.isElementVisible(element); + }, + + getFormBindings() { + return { + novalidate: true, + }; + }, + + convertToFormData: FormDataUtils.convertToFormData.bind(FormDataUtils), + serializeParams: FormDataUtils.serializeParams.bind(FormDataUtils), + + setupFormListeners(): void { + const component = this as unknown as AlpineComponent; + const formElement = component.$el; + + if (!formElement) { + this.cleanup = () => { + if (this.formId) { + document.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.FORM_UNREGISTER, { + detail: { id: this.formId }, + }), + ); + } + }; + return; + } + + const handleFormSubmit = (event: Event) => { + event.preventDefault(); + }; + + formElement.addEventListener('submit', handleFormSubmit); + + this.cleanup = () => { + formElement.removeEventListener('submit', handleFormSubmit); + if (this.formId) { + document.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.FORM_UNREGISTER, { + detail: { id: this.formId }, + }), + ); + } + }; + }, + + updateFieldRef(name: string): void { + const component = this as unknown as AlpineComponent; + const formElement = component.$el; + if (!formElement) return; + + const element = DOMUtils.getFieldElement(formElement, name) as HTMLInputElement; + const field = this.fields[name]; + + if (element && field) { + field.ref = element; + } + }, + + clearSingleError(name: string): void { + delete this.errors[name]; + this.updateAriaInvalidState(name); + }, + + updateAriaInvalidState(name: string): void { + const fieldRef = this.fields[name]?.ref; + if (!fieldRef) return; + + DOMUtils.setAriaInvalid(fieldRef, !!this.errors[name]); + }, + + updateFormValidState(): void { + this.isValid = Object.keys(this.errors).length === 0; + }, + + focusFirstError(): void { + const firstErrorField = Object.keys(this.errors)[0]; + if (firstErrorField) { + this.setFocus(firstErrorField); + } + }, + + syncDOMWithState(): void { + for (const [name, value] of Object.entries(this.values)) { + const fieldRef = this.fields[name]?.ref; + if (fieldRef) { + // Handle checkbox arrays specially + if (Array.isArray(value) && fieldRef.type === 'checkbox') { + this.syncCheckboxArray(name, value as string[]); + } else { + DOMUtils.updateElementValue(fieldRef, value); + } + } + } + }, + + syncCheckboxArray(name: string, values: string[]): void { + const component = this as unknown as AlpineComponent; + const formElement = component.$el.closest('form') || component.$el.parentElement; + const checkboxes = formElement?.querySelectorAll(`input[type="checkbox"][name="${name}"]`); + + if (checkboxes) { + checkboxes.forEach((checkbox) => { + const input = checkbox as HTMLInputElement; + input.checked = values.includes(input.value); + }); + } + }, + + clearAllState(): void { + this.fields = {}; + this.values = {}; + this.errors = {}; + this.touchedFields = {}; + this.dirtyFields = {}; + }, + }; + + return formInstance; +}; + +export const formMeta: AlpineComponentMeta = { + name: 'form', + component: form, +}; diff --git a/assets/core/ts/components/icon.ts b/assets/core/ts/components/icon.ts new file mode 100644 index 0000000000..0060acbb66 --- /dev/null +++ b/assets/core/ts/components/icon.ts @@ -0,0 +1,128 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; + +import { tutorConfig } from '@TutorShared/config/config'; + +interface IconCacheEntry { + svg?: string; + loading?: boolean; + promise?: Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error?: any; +} + +const iconCache: Record = {}; + +export interface IconProps { + name: string; // Use PHP Icon:: to get the name. + width?: number; + height?: number; + from: 'php' | 'ts'; + ignoreKids?: boolean; +} + +const createSvg = ({ + width, + height, + viewBox, + fill, + content = '', +}: { + width: number; + height: number; + viewBox?: string; + fill?: string; + content?: string; +}) => + `${content}`; + +function fetchSVG( + name: string, + width: number, + height: number, + from: 'php' | 'ts' = 'ts', + ignoreKids = false, +): Promise { + const fileName = from === 'php' ? name : name.trim().replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); + const hasKidsVariant = !ignoreKids && tutorConfig.is_kids_mode && tutorConfig.kids_icons_registry?.includes(fileName); + + const basePath = hasKidsVariant ? 'assets/icons/kids/' : 'assets/icons/'; + const url = `${tutorConfig.tutor_url}${basePath}${fileName}.svg`; + const defaultViewBox = `0 0 ${width} ${height}`; + + if (iconCache[url]?.svg) { + return Promise.resolve(iconCache[url].svg!); + } + + if (iconCache[url]?.promise) { + return iconCache[url].promise!; + } + + const promise = fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.text(); + }) + .then((svgText) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgText, 'image/svg+xml'); + const svgEl = doc.querySelector('svg'); + const viewBox = svgEl?.getAttribute('viewBox') || defaultViewBox; + const fill = svgEl?.getAttribute('fill') || 'none'; + const content = svgEl?.innerHTML || ''; + + const svgMarkup = createSvg({ width, height, viewBox, fill, content }); + iconCache[url] = { svg: svgMarkup }; + return svgMarkup; + }) + .catch((error) => { + iconCache[url] = { error }; + // eslint-disable-next-line no-console + console.error(`Failed to load icon: ${fileName}`, error); + return createSvg({ width, height }); + }); + + iconCache[url] = { loading: true, promise }; + return promise; +} + +export const icon = (props: IconProps) => ({ + name: props.name, + width: props.width || 16, + height: props.height || 16, + from: props.from || 'php', + ignoreKids: props.ignoreKids || false, + + async init() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $el = (this as any).$el as HTMLElement; + $el.innerHTML = createSvg({ width: this.width, height: this.height }); + $el.classList.add('tutor-icon'); + + if (!this.name) { + return; + } + + const svg = await fetchSVG(this.name, this.width, this.height, this.from, this.ignoreKids); + + $el.innerHTML = svg; + }, + + async updateIcon(newName: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $el = (this as any).$el as HTMLElement; + this.name = newName; + + $el.innerHTML = createSvg({ width: this.width, height: this.height }); + + const svg = await fetchSVG(this.name, this.width, this.height, this.from, this.ignoreKids); + $el.innerHTML = svg; + }, +}); + +export const iconMeta: AlpineComponentMeta = { + name: 'icon', + component: icon, + global: true, +}; diff --git a/assets/core/ts/components/modal.ts b/assets/core/ts/components/modal.ts new file mode 100644 index 0000000000..e2242aefd9 --- /dev/null +++ b/assets/core/ts/components/modal.ts @@ -0,0 +1,125 @@ +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; +import { type AlpineComponentMeta } from '@Core/ts/types'; + +export interface ModalConfig { + id: string; + isCloseable?: boolean; // if true, the modal can be closed by clicking outside or pressing escape + initialOpen?: boolean; // if true, the modal is shown on init (default: false) +} + +const DEFAULT_CONFIG: ModalConfig = { + id: 'tutor-modal', + isCloseable: true, +}; + +export const modal = (config: ModalConfig = { ...DEFAULT_CONFIG }) => ({ + open: config.initialOpen ?? false, + payload: null, + isCloseable: config.isCloseable ?? DEFAULT_CONFIG.isCloseable, + id: config.id, + cleanup: undefined as (() => void) | undefined, + $el: undefined as HTMLElement | undefined, + + init(): void { + const onOpen = (event: CustomEvent) => { + const targetId = event?.detail?.id as string | undefined; + if (!targetId || targetId === this.id) { + this.payload = event?.detail?.data ?? null; + + this.show(); + } + }; + + const onClose = (event: CustomEvent) => { + const targetId = event?.detail?.id as string | undefined; + if (!targetId || targetId === this.id) { + this.close(); + } + }; + + document.addEventListener(TUTOR_CUSTOM_EVENTS.MODAL_OPEN, onOpen as EventListener); + document.addEventListener(TUTOR_CUSTOM_EVENTS.MODAL_CLOSE, onClose as EventListener); + + this.cleanup = () => { + document.removeEventListener(TUTOR_CUSTOM_EVENTS.MODAL_OPEN, onOpen as EventListener); + document.removeEventListener(TUTOR_CUSTOM_EVENTS.MODAL_CLOSE, onClose as EventListener); + }; + }, + + destroy(): void { + this.cleanup?.(); + }, + + show(): void { + this.open = true; + }, + + close(): void { + this.open = false; + this.payload = null; + document.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.MODAL_CLOSED, { + detail: { id: this.id }, + }), + ); + }, + + getBackdropBindings() { + const backdrop = this.$el as HTMLElement; + backdrop.classList.add('tutor-modal-backdrop'); + + return { + 'x-show': 'open', + 'x-transition:enter': 'tutor-modal-backdrop-enter', + 'x-transition:enter-start': 'tutor-modal-backdrop-transition', + 'x-transition:enter-end': 'tutor-modal-backdrop-transition-reset', + 'x-transition:leave': 'tutor-modal-backdrop-leave', + 'x-transition:leave-start': 'tutor-modal-backdrop-transition-reset', + 'x-transition:leave-end': 'tutor-modal-backdrop-transition', + }; + }, + + getModalBindings() { + const modal = this.$el as HTMLElement; + modal.classList.add('tutor-modal'); + + return { + 'x-show': 'open', + '@keydown.escape.window': this.isCloseable ? 'close()' : '', + role: 'dialog', + 'aria-modal': 'true', + }; + }, + + getModalContentBindings() { + const modal = this.$el as HTMLElement; + modal.classList.add('tutor-modal-content'); + + return { + 'x-trap.noscroll.inert': 'open', + '@click.outside': this.isCloseable ? 'close()' : '', + 'x-show': 'open', + 'x-transition:enter': 'tutor-modal-content-enter', + 'x-transition:enter-start': 'tutor-modal-content-transition', + 'x-transition:enter-end': 'tutor-modal-content-transition-reset', + 'x-transition:leave': 'tutor-modal-content-leave', + 'x-transition:leave-start': 'tutor-modal-content-transition-reset', + 'x-transition:leave-end': 'tutor-modal-content-transition', + }; + }, + + getCloseButtonBindings() { + const closeButton = this.$el as HTMLElement; + closeButton.classList.add('tutor-modal-close'); + + return { + 'x-show': 'open', + '@click': 'close()', + }; + }, +}); + +export const modalMeta: AlpineComponentMeta = { + name: 'modal', + component: modal, +}; diff --git a/assets/core/ts/components/password-input.ts b/assets/core/ts/components/password-input.ts new file mode 100644 index 0000000000..d82fd27a00 --- /dev/null +++ b/assets/core/ts/components/password-input.ts @@ -0,0 +1,122 @@ +import { __ } from '@wordpress/i18n'; + +import { type AlpineComponentMeta } from '@Core/ts/types'; + +export interface PasswordInputProps { + showStrength?: boolean; + minStrength?: number; +} + +const defaultProps: PasswordInputProps = { + showStrength: false, + minStrength: 3, +}; + +type StrengthLevel = 0 | 1 | 2 | 3 | 4 | 5; + +const strengthLabels: Record = { + 0: __('Very weak', 'tutor'), + 1: __('Very weak', 'tutor'), + 2: __('Weak', 'tutor'), + 3: __('Medium', 'tutor'), + 4: __('Strong', 'tutor'), + 5: __('Very strong', 'tutor'), +}; + +const strengthColors: Record = { + 0: 'var(--tutor-text-critical)', + 1: 'var(--tutor-text-critical)', + 2: 'var(--tutor-text-critical)', + 3: 'var(--tutor-text-warning)', + 4: 'var(--tutor-text-success)', + 5: 'var(--tutor-text-success)', +}; + +export const passwordInput = (props: PasswordInputProps = defaultProps) => ({ + showPassword: false, + strength: 0 as StrengthLevel, + strengthLabel: '', + strengthColor: '', + showStrength: props.showStrength ?? false, + minStrength: props.minStrength ?? 3, + password: '', + + init() { + const input = (this as unknown as { $el: HTMLElement }).$el.querySelector('input') as HTMLInputElement; + if (input) { + input.addEventListener('input', (e) => { + this.password = (e.target as HTMLInputElement).value; + if (this.showStrength) { + this.checkStrength(); + } + }); + } + }, + + destroy() { + const input = (this as unknown as { $el: HTMLElement }).$el.querySelector('input') as HTMLInputElement; + if (input) { + input.removeEventListener('input', (e) => { + this.password = (e.target as HTMLInputElement).value; + if (this.showStrength) { + this.checkStrength(); + } + }); + } + }, + + toggleVisibility() { + this.showPassword = !this.showPassword; + const root = (this as unknown as { $root: HTMLElement }).$root; + const input = root.querySelector('input') as HTMLInputElement; + + if (input) { + input.type = this.showPassword ? 'text' : 'password'; + } + }, + + checkStrength() { + this.strength = this.calculateBasicStrength(); + + this.strengthLabel = strengthLabels[this.strength] || ''; + this.strengthColor = strengthColors[this.strength] || ''; + }, + + calculateBasicStrength(): StrengthLevel { + const pwd = this.password; + if (!pwd) { + return 0; + } + + let score = 0; + + if (pwd.length >= 8) score++; + if (pwd.length >= 12) score++; + if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++; + if (/\d/.test(pwd)) score++; + if (/[^a-zA-Z0-9]/.test(pwd)) score++; + + return Math.min(score, 5) as StrengthLevel; + }, + + getToggleBindings() { + return { + '@click': 'toggleVisibility()', + class: 'tutor-password-toggle', + type: 'button', + 'aria-label': this.showPassword ? __('Hide password', 'tutor') : __('Show password', 'tutor'), + }; + }, + + getStrengthTextBindings() { + return { + 'x-text': 'strengthLabel', + ':style': `{color: strengthColor}`, + }; + }, +}); + +export const passwordInputMeta: AlpineComponentMeta = { + name: 'passwordInput', + component: passwordInput, +}; diff --git a/assets/core/ts/components/player.ts b/assets/core/ts/components/player.ts new file mode 100644 index 0000000000..a7fd67a389 --- /dev/null +++ b/assets/core/ts/components/player.ts @@ -0,0 +1,92 @@ +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { isMobileDevice } from '@Core/ts/utils/util'; + +import { isVimeoPlyr } from '@FrontendTypes/index'; + +export interface PlayerProps { + config?: Plyr.Options; +} + +export interface AlpinePlayerData { + $el?: HTMLElement; + plyr: Plyr | null; + init(): void; +} + +export const player = (props: PlayerProps = {}): AlpinePlayerData => ({ + plyr: null, + $el: undefined as HTMLElement | undefined, + + init() { + if (typeof window.Plyr === 'undefined') { + // eslint-disable-next-line no-console + console.warn('Plyr is not defined. Ensure Plyr library is loaded.'); + return; + } + + if (!this.$el) { + return; + } + + this.plyr = new window.Plyr(this.$el, props.config); + + if (this.plyr) { + this.plyr.on('ready', () => { + // Remove loading spinner + this.$el?.closest('.tutor-video-player')?.querySelector('.loading-spinner')?.remove(); + + /** + * Fix: Mobile Vimeo autoplay sound issue + * Always start muted on mobile to comply with autoplay policy. + */ + if (this.plyr && isVimeoPlyr(this.plyr) && isMobileDevice()) { + try { + this.plyr.muted = true; + if (typeof this.plyr.mute === 'function') { + this.plyr.mute(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Vimeo mute init failed:', err); + } + } + }); + + this.plyr.on('play', () => { + /** + * Unmute automatically after first user interaction + * Mobile browsers allow audio only after gesture. + */ + if (this.plyr && isVimeoPlyr(this.plyr) && isMobileDevice()) { + try { + this.plyr.muted = false; + if (typeof this.plyr.unmute === 'function') { + this.plyr.unmute(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Vimeo unmute on play failed:', err); + } + } + }); + } + + // Dispatch custom event when player is ready + this.$el.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.TUTOR_PLAYER_READY, { + detail: { + plyr: this.plyr, + component: this, + }, + bubbles: true, + }), + ); + }, +}); + +export const playerMeta: AlpineComponentMeta = { + name: 'player', + component: player, + global: true, +}; diff --git a/assets/core/ts/components/popover.ts b/assets/core/ts/components/popover.ts new file mode 100644 index 0000000000..356ebb72ce --- /dev/null +++ b/assets/core/ts/components/popover.ts @@ -0,0 +1,437 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { isRTL } from '@Core/ts/utils/util'; + +const PLACEMENTS = { + TOP: 'top', + TOP_START: 'top-start', + TOP_END: 'top-end', + BOTTOM: 'bottom', + BOTTOM_START: 'bottom-start', + BOTTOM_END: 'bottom-end', + LEFT: 'left', + LEFT_TOP: 'left-top', + LEFT_BOTTOM: 'left-bottom', + RIGHT: 'right', + RIGHT_TOP: 'right-top', + RIGHT_BOTTOM: 'right-bottom', +} as const; + +export interface PopoverProps { + placement?: (typeof PLACEMENTS)[keyof typeof PLACEMENTS]; + offset?: number; + onShow?: () => void; + onHide?: () => void; +} + +interface PopoverDimensions { + width: number; + height: number; +} + +export const popover = (props: PopoverProps = {}) => ({ + open: false, + placement: props.placement || PLACEMENTS.BOTTOM_START, + offset: props.offset ?? 4, + actualPlacement: '', + $el: undefined as HTMLElement | undefined, + $refs: {} as { trigger: HTMLElement; content: HTMLElement }, + $nextTick: undefined as ((callback: () => void) => void) | undefined, + scrollHandler: null as (() => void) | null, + resizeHandler: null as (() => void) | null, + escapeHandler: null as ((e: KeyboardEvent) => void) | null, + + init() { + this.actualPlacement = this.getActualPlacement(); + this.setupEventListeners(); + }, + + destroy() { + this.removeEventListeners(); + }, + + setupEventListeners() { + this.scrollHandler = () => { + if (this.open) { + this.updatePosition(); + } + }; + + this.resizeHandler = () => { + if (this.open) { + this.updatePosition(); + } + }; + + this.escapeHandler = (e: KeyboardEvent) => { + this.handleEscapeKeydown(e); + }; + + window.addEventListener('scroll', this.scrollHandler, true); + window.addEventListener('resize', this.resizeHandler); + document.addEventListener('keydown', this.escapeHandler); + }, + + removeEventListeners() { + if (this.scrollHandler) { + window.removeEventListener('scroll', this.scrollHandler, true); + } + + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + + if (this.escapeHandler) { + document.removeEventListener('keydown', this.escapeHandler); + this.escapeHandler = null; + } + }, + + getActualPlacement() { + if (!isRTL) return this.placement; + + const rtlAdaptations: Record = { + [PLACEMENTS.LEFT]: PLACEMENTS.RIGHT, + [PLACEMENTS.LEFT_TOP]: PLACEMENTS.RIGHT_TOP, + [PLACEMENTS.LEFT_BOTTOM]: PLACEMENTS.RIGHT_BOTTOM, + [PLACEMENTS.RIGHT]: PLACEMENTS.LEFT, + [PLACEMENTS.RIGHT_TOP]: PLACEMENTS.LEFT_TOP, + [PLACEMENTS.RIGHT_BOTTOM]: PLACEMENTS.LEFT_BOTTOM, + [PLACEMENTS.TOP_START]: PLACEMENTS.TOP_END, + [PLACEMENTS.TOP_END]: PLACEMENTS.TOP_START, + [PLACEMENTS.BOTTOM_START]: PLACEMENTS.BOTTOM_END, + [PLACEMENTS.BOTTOM_END]: PLACEMENTS.BOTTOM_START, + }; + + return rtlAdaptations[this.placement] || this.placement; + }, + + show(): void { + const content = this.$refs.content; + if (content) { + content.style.visibility = 'hidden'; + // Initialize with off-screen position to avoid flashes if visibility fails + content.style.left = '-9999px'; + content.style.top = '-9999px'; + } + + this.open = true; + + const afterShow = () => { + if (!this.open) return; + + const dimensions = this.getContentDimensions(content); + // If measurement failed (0 size), Safari might not have rendered it yet. Retry. + if (dimensions.width === 0 && dimensions.height === 0) { + requestAnimationFrame(afterShow); + return; + } + + this.updatePosition(); + if (content) { + content.style.visibility = 'visible'; + } + if (props.onShow) { + props.onShow(); + } + }; + + if (this.$nextTick) { + this.$nextTick(afterShow); + } else { + requestAnimationFrame(afterShow); + } + }, + + hide(): void { + this.open = false; + const content = this.$refs.content; + if (content) { + content.style.visibility = 'hidden'; + } + if (props.onHide) { + props.onHide(); + } + }, + + toggle(): void { + if (this.open) { + this.hide(); + } else { + this.show(); + } + }, + + handleClickOutside(): void { + if (this.open) { + this.hide(); + } + }, + + handleEscapeKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.open) { + event.preventDefault(); + event.stopPropagation(); + this.hide(); + } + }, + + isTriggerVisible(): boolean { + const trigger = this.$refs.trigger; + if (!trigger) return false; + + const rect = trigger.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + + // Check if trigger is within viewport bounds + const isWithinViewport = + rect.bottom > 0 && rect.top < viewportHeight && rect.right > 0 && rect.left < viewportWidth; + + if (!isWithinViewport || rect.width === 0 || rect.height === 0) { + return false; + } + + // Check if trigger is obscured by elements like sticky headers + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const elementAtPoint = document.elementFromPoint(centerX, centerY); + + if (elementAtPoint) { + const content = this.$refs.content; + const isTriggerOrChild = trigger === elementAtPoint || trigger.contains(elementAtPoint); + const isContentOrChild = content && (content === elementAtPoint || content.contains(elementAtPoint)); + + if (!isTriggerOrChild && !isContentOrChild) { + return false; + } + } + + return true; + }, + + updatePosition(): void { + const trigger = this.$refs.trigger; + const content = this.$refs.content; + + if (!trigger || !content) return; + + if (this.open && !this.isTriggerVisible()) { + this.hide(); + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const contentDimensions = this.getContentDimensions(content); + + // If measurement failed (0 size), retry on next frame + if (this.open && contentDimensions.width === 0 && contentDimensions.height === 0) { + requestAnimationFrame(() => this.updatePosition()); + return; + } + + const viewport = { + width: window.innerWidth, + height: window.innerHeight, + }; + + const placement = this.resolvePlacement(this.actualPlacement, triggerRect, contentDimensions, viewport); + const viewportPosition = this.calculatePosition(triggerRect, contentDimensions, placement); + const { top, left } = this.convertViewportPositionToContentPosition(content, viewportPosition); + + // Apply positioning + content.style.position = 'fixed'; + content.style.top = `${top}px`; + content.style.left = `${left}px`; + content.style.zIndex = '1060'; + + // Update CSS classes for placement + this.updatePlacementClasses(content, placement); + }, + + resolvePlacement( + placement: string, + triggerRect: DOMRect, + contentDimensions: PopoverDimensions, + viewport: { width: number; height: number }, + ): string { + const space = { + top: triggerRect.top, + bottom: viewport.height - triggerRect.bottom, + left: triggerRect.left, + right: viewport.width - triggerRect.right, + }; + + const needsVerticalFlip = { + top: space.top < contentDimensions.height + this.offset && space.bottom > space.top, + bottom: space.bottom < contentDimensions.height + this.offset && space.top > space.bottom, + }; + + const needsHorizontalFlip = { + left: space.left < contentDimensions.width + this.offset && space.right > space.left, + right: space.right < contentDimensions.width + this.offset && space.left > space.right, + }; + + if (placement.startsWith('top') && needsVerticalFlip.top) { + return placement.replace('top', 'bottom'); + } + + if (placement.startsWith('bottom') && needsVerticalFlip.bottom) { + return placement.replace('bottom', 'top'); + } + + if (placement.startsWith('left') && needsHorizontalFlip.left) { + return placement.replace('left', 'right'); + } + + if (placement.startsWith('right') && needsHorizontalFlip.right) { + return placement.replace('right', 'left'); + } + + return placement; + }, + + calculatePosition(triggerRect: DOMRect, contentDimensions: PopoverDimensions, placement: string) { + let top = 0; + let left = 0; + + switch (placement) { + case PLACEMENTS.TOP: + top = triggerRect.top - contentDimensions.height - this.offset; + left = triggerRect.left + (triggerRect.width - contentDimensions.width) / 2; + break; + case PLACEMENTS.TOP_START: + top = triggerRect.top - contentDimensions.height - this.offset; + left = triggerRect.left; + break; + case PLACEMENTS.TOP_END: + top = triggerRect.top - contentDimensions.height - this.offset; + left = triggerRect.right - contentDimensions.width; + break; + case PLACEMENTS.BOTTOM: + top = triggerRect.bottom + this.offset; + left = triggerRect.left + (triggerRect.width - contentDimensions.width) / 2; + break; + case PLACEMENTS.BOTTOM_START: + top = triggerRect.bottom + this.offset; + left = triggerRect.left; + break; + case PLACEMENTS.BOTTOM_END: + top = triggerRect.bottom + this.offset; + left = triggerRect.right - contentDimensions.width; + break; + case PLACEMENTS.LEFT: + top = triggerRect.top + (triggerRect.height - contentDimensions.height) / 2; + left = triggerRect.left - contentDimensions.width - this.offset; + break; + case PLACEMENTS.LEFT_TOP: + top = triggerRect.top; + left = triggerRect.left - contentDimensions.width - this.offset; + break; + case PLACEMENTS.LEFT_BOTTOM: + top = triggerRect.bottom - contentDimensions.height; + left = triggerRect.left - contentDimensions.width - this.offset; + break; + case PLACEMENTS.RIGHT: + top = triggerRect.top + (triggerRect.height - contentDimensions.height) / 2; + left = triggerRect.right + this.offset; + break; + case PLACEMENTS.RIGHT_TOP: + top = triggerRect.top; + left = triggerRect.right + this.offset; + break; + case PLACEMENTS.RIGHT_BOTTOM: + top = triggerRect.bottom - contentDimensions.height; + left = triggerRect.right + this.offset; + break; + } + + return { top, left }; + }, + + getContentDimensions(content: HTMLElement): PopoverDimensions { + // Temporarily reset transforms/transitions for accurate measurement + const originalTransform = content.style.transform; + const originalTransition = content.style.transition; + content.style.transform = 'none'; + content.style.transition = 'none'; + + const rect = content.getBoundingClientRect(); + const dimensions = { + width: content.offsetWidth || rect.width, + height: content.offsetHeight || rect.height, + }; + + content.style.transform = originalTransform; + content.style.transition = originalTransition; + + return dimensions; + }, + + convertViewportPositionToContentPosition(content: HTMLElement, position: { top: number; left: number }) { + const containingBlock = this.getFixedContainingBlock(content); + + if (!containingBlock) { + return position; + } + + const containingBlockRect = containingBlock.getBoundingClientRect(); + const scaleX = containingBlock.offsetWidth ? containingBlockRect.width / containingBlock.offsetWidth || 1 : 1; + const scaleY = containingBlock.offsetHeight ? containingBlockRect.height / containingBlock.offsetHeight || 1 : 1; + + return { + top: (position.top - containingBlockRect.top) / scaleY - containingBlock.clientTop, + left: (position.left - containingBlockRect.left) / scaleX - containingBlock.clientLeft, + }; + }, + + getFixedContainingBlock(element: HTMLElement) { + let parent = element.parentElement; + + while (parent && parent !== document.documentElement) { + if (this.createsFixedContainingBlock(parent)) { + return parent; + } + + parent = parent.parentElement; + } + + return null; + }, + + createsFixedContainingBlock(element: HTMLElement) { + const style = window.getComputedStyle(element); + const willChangeProperties = style.willChange.split(',').map((property) => property.trim()); + const containProperties = style.contain.split(' '); + const backdropFilter = + style.getPropertyValue('backdrop-filter') || style.getPropertyValue('-webkit-backdrop-filter'); + const contentVisibility = style.getPropertyValue('content-visibility'); + const containerType = style.getPropertyValue('container-type'); + + return ( + style.transform !== 'none' || + style.perspective !== 'none' || + style.filter !== 'none' || + (backdropFilter !== '' && backdropFilter !== 'none') || + contentVisibility === 'auto' || + (containerType !== '' && containerType !== 'normal') || + willChangeProperties.some((property) => ['transform', 'perspective', 'filter'].includes(property)) || + containProperties.some((property) => ['layout', 'paint', 'strict', 'content'].includes(property)) + ); + }, + + updatePlacementClasses(content: HTMLElement, placement: string) { + // Remove all placement classes + const placementClasses = ['tutor-popover-top', 'tutor-popover-bottom', 'tutor-popover-left', 'tutor-popover-right']; + content.classList.remove(...placementClasses); + + // Add current placement class + const basePlacement = placement.split('-')[0]; + content.classList.add(`tutor-popover-${basePlacement}`); + }, +}); + +export const popoverMeta: AlpineComponentMeta = { + name: 'popover', + component: popover, +}; diff --git a/assets/core/ts/components/preview-trigger.ts b/assets/core/ts/components/preview-trigger.ts new file mode 100644 index 0000000000..4625cc8606 --- /dev/null +++ b/assets/core/ts/components/preview-trigger.ts @@ -0,0 +1,212 @@ +// Preview Trigger Component +// Shows course/lesson preview card on hover + +import { __, sprintf } from '@wordpress/i18n'; + +import { type AlpineComponentMeta } from '@Core/ts/types'; + +import { popover, type PopoverProps } from './popover'; + +export interface PreviewData { + title: string; + url: string; + thumbnail: string; + instructor: string; + instructor_url: string; +} + +export interface PreviewTriggerProps extends PopoverProps { + data?: PreviewData; + delay?: number; +} + +export const previewTrigger = (props: PreviewTriggerProps = {}) => { + const popoverInstance = popover({ + placement: props.placement || 'bottom-start', + offset: props.offset ?? 4, + onShow: props.onShow, + onHide: props.onHide, + }); + + return { + ...popoverInstance, + previewData: props.data || null, + isTouchDevice: false, + hoverTimeout: null as number | null, + hoverDelay: props.delay || 300, + $nextTick: undefined as ((callback: () => void) => void) | undefined, + + init() { + popoverInstance.init.call(this); + this.isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + this.setupPreviewTriggers(); + }, + + setupPreviewTriggers() { + const trigger = this.$refs.trigger; + if (!trigger) return; + + // Get hover delay from data attribute + if (trigger.hasAttribute('data-tutor-preview-delay')) { + this.hoverDelay = parseInt(trigger.getAttribute('data-tutor-preview-delay') || '300', 10); + } + + if (this.isTouchDevice) { + // Mobile: tap to toggle + trigger.addEventListener('click', (e: Event) => this.handleTap(e as MouseEvent)); + + // Prevent clicks inside content from bubbling to trigger on mobile + // This ensures links inside content work and tapping content doesn't toggle the popover + const content = this.$refs.content; + if (content) { + content.addEventListener('click', (e: Event) => { + e.stopPropagation(); + }); + } + } else { + // Desktop: hover to show + trigger.addEventListener('mouseenter', () => this.handleMouseEnter()); + trigger.addEventListener('mouseleave', () => this.handleMouseLeave()); + trigger.addEventListener('keydown', (e: KeyboardEvent) => this.handleKeyDown(e)); + + // Keep popover open when hovering over content + const content = this.$refs.content; + if (content) { + content.addEventListener('mouseenter', () => { + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + this.hoverTimeout = null; + } + }); + content.addEventListener('mouseleave', () => this.handleMouseLeave()); + } + } + }, + + handleTap(event: MouseEvent) { + const target = event.target as HTMLElement; + const content = this.$refs.content; + + // If the tap is inside the preview content, let it be handled there + // This is a fallback in case propagation wasn't stopped + if (content && content.contains(target)) { + return; + } + + event.preventDefault(); + if (this.open) { + this.hide(); + return; + } + this.showPreview(); + }, + + handleMouseEnter() { + // Clear any existing timeout + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + } + + // Show after delay + this.hoverTimeout = window.setTimeout(() => { + this.showPreview(); + }, this.hoverDelay); + }, + + handleMouseLeave() { + // Clear timeout if user moves away before delay + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + this.hoverTimeout = null; + } + + // Hide preview after a short delay + setTimeout(() => { + const content = this.$refs.content; + if (!content?.matches(':hover') && !this.$refs.trigger?.matches(':hover')) { + this.hide(); + } + }, 100); + }, + + handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.hide(); + } + + if (event.key === 'Enter' || event.key === ' ') { + const content = this.$refs.content; + if (content && content.contains(event.target as Node)) { + return; + } + + event.preventDefault(); + if (this.open) { + this.hide(); + } else { + this.showPreview(); + } + } + }, + + showPreview() { + if (!this.previewData) return; + + // Show popover + this.show(); + // Render content + this.renderPreview(); + + // Reposition after content is rendered + if (this.$nextTick) { + this.updatePosition(); + } else { + this.updatePosition(); + } + }, + + renderPreview() { + const content = this.$refs.content; + if (!content || !this.previewData) return; + + this.renderCoursePreview(content); + }, + + renderCoursePreview(content: HTMLElement) { + if (!this.previewData) return; + + const data = this.previewData; + const thumbnailHtml = data.thumbnail + ? `${this.escapeHtml(data.title)}` + : ''; + + content.innerHTML = ` +
+ ${data.thumbnail ? (data.url ? `${thumbnailHtml}` : thumbnailHtml) : ''} +
+

${data.url ? `${this.escapeHtml(data.title)}` : this.escapeHtml(data.title)}

+ ${data.instructor ? `
${sprintf(__(`by %s`, 'tutor'), this.escapeHtml(data.instructor))}
` : ''} +
+
+ `; + }, + + escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + destroy() { + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + } + popoverInstance.destroy.call(this); + }, + }; +}; + +export const previewTriggerMeta: AlpineComponentMeta = { + name: 'previewTrigger', + component: previewTrigger, +}; diff --git a/assets/core/ts/components/read-more.ts b/assets/core/ts/components/read-more.ts new file mode 100644 index 0000000000..d253fef46e --- /dev/null +++ b/assets/core/ts/components/read-more.ts @@ -0,0 +1,141 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; + +interface ReadMoreProps { + lines?: number; + expanded?: boolean; +} + +const DEFAULT_LINES = 4; + +const readMore = (props: ReadMoreProps = {}) => { + return { + expanded: props.expanded ?? false, + hasOverflow: false, + lines: props.lines ?? DEFAULT_LINES, + collapsedHeightPx: 0, + $el: null as HTMLElement | null, + $refs: {} as { content?: HTMLElement; readMore?: HTMLElement; readLess?: HTMLElement }, + $watch: null as ((key: string, callback: () => void) => void) | null, + $nextTick: null as ((callback: () => void) => void) | null, + + init() { + const content = this.$refs.content; + if (!content) { + return; + } + + // Clear the inline line-clamp styles that prevented flash of full + // content (set in the PHP template) and switch to max-height. + content.style.display = ''; + content.style.webkitLineClamp = ''; + content.style.setProperty('-webkit-box-orient', ''); + content.style.removeProperty('-webkit-line-clamp'); + content.style.removeProperty('-webkit-box-orient'); + content.style.removeProperty('display'); + + this.computeCollapsedHeight(content); + this.applyCollapsedStyles(content); + + this.$watch?.('expanded', () => this.applyState()); + + this.$nextTick?.(() => { + this.sync(); + }); + }, + + computeCollapsedHeight(content: HTMLElement) { + const computedStyle = getComputedStyle(content); + let lineHeight = parseFloat(computedStyle.lineHeight); + + // If lineHeight is 'normal', estimate from font-size × 1.2. + if (Number.isNaN(lineHeight)) { + lineHeight = parseFloat(computedStyle.fontSize) * 1.2; + } + + this.collapsedHeightPx = Math.ceil(this.lines * lineHeight); + }, + + sync() { + const content = this.$refs.content; + if (!content) { + return; + } + + this.computeCollapsedHeight(content); + + const previousMaxHeight = content.style.maxHeight; + const previousOverflow = content.style.overflow; + + content.style.maxHeight = `${this.collapsedHeightPx}px`; + content.style.overflow = 'hidden'; + + this.hasOverflow = content.scrollHeight > content.clientHeight + 1; + + content.style.maxHeight = previousMaxHeight; + content.style.overflow = previousOverflow; + + this.applyState(); + }, + + applyCollapsedStyles(content: HTMLElement) { + content.style.maxHeight = `${this.collapsedHeightPx}px`; + content.style.overflow = 'hidden'; + }, + + applyState() { + const root = this.$el; + const content = this.$refs.content; + const readMoreBtn = this.$refs.readMore; + + if (!content || !root) { + return; + } + + // Root needs relative positioning for the absolute "read more" button. + root.style.position = 'relative'; + + if (this.expanded || !this.hasOverflow) { + content.style.maxHeight = 'none'; + content.style.overflow = 'visible'; + this.styleReadMoreButton(readMoreBtn, false); + return; + } + + content.style.maxHeight = `${this.collapsedHeightPx}px`; + content.style.overflow = 'hidden'; + this.styleReadMoreButton(readMoreBtn, true); + }, + + styleReadMoreButton(btn: HTMLElement | undefined, collapsed: boolean) { + if (!btn) { + return; + } + + if (!collapsed) { + btn.style.position = ''; + btn.style.bottom = ''; + btn.style.right = ''; + btn.style.paddingLeft = ''; + return; + } + + btn.style.position = 'absolute'; + btn.style.bottom = '0'; + btn.style.right = '0'; + btn.style.paddingLeft = '4px'; + }, + + toggle() { + if (!this.hasOverflow && !this.expanded) { + return; + } + + this.expanded = !this.expanded; + }, + }; +}; + +export const readMoreMeta: AlpineComponentMeta = { + name: 'readMore', + component: readMore, +}; diff --git a/assets/core/ts/components/select.ts b/assets/core/ts/components/select.ts new file mode 100644 index 0000000000..f2d5ec5759 --- /dev/null +++ b/assets/core/ts/components/select.ts @@ -0,0 +1,704 @@ +/** + * Tutor Select Component + * + * @package Tutor\Core + * @since 4.0.0 + */ + +import { type FormControlMethods } from '@Core/ts/components/form'; +import { type AlpineComponentMeta } from '@Core/ts/types'; + +export interface SelectOption { + label: string; + value: string | number; + disabled?: boolean; + icon?: string; + description?: string; + group?: string; + display_label?: string; +} + +export interface SelectGroup { + label: string; + options: SelectOption[]; +} + +export interface SelectProps { + // Data + options?: SelectOption[]; + groups?: SelectGroup[]; + value?: string | number | (string | number)[]; + defaultValue?: string | number | (string | number)[]; + + // Multi-select + multiple?: boolean; + maxSelections?: number; + + // Behavior + searchable?: boolean; + clearable?: boolean; + disabled?: boolean; + loading?: boolean; + closeOnSelect?: boolean; + + // Display + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + loadingMessage?: string; + maxHeight?: number; + + // Form integration + name?: string; + required?: boolean | string; + + // Callbacks + onChange?: (value: string | number | (string | number)[]) => void; + onSearch?: (query: string) => void | Promise; + onOpen?: () => void; + onClose?: () => void; +} + +interface SelectState { + isOpen: boolean; + searchQuery: string; + highlightedIndex: number; + selectedValues: Set; + isLoading: boolean; + asyncOptions: SelectOption[]; + dropdownPosition: 'top' | 'bottom'; + isFocused: boolean; +} + +export const select = (props: SelectProps = {}) => { + const state: SelectState = { + isOpen: false, + searchQuery: '', + highlightedIndex: -1, + selectedValues: new Set(), + isLoading: false, + asyncOptions: [], + dropdownPosition: 'bottom', + isFocused: false, + }; + + return { + // Props with defaults + options: props.options || [], + groups: props.groups || [], + multiple: props.multiple || false, + searchable: props.searchable || false, + clearable: props.clearable || false, + disabled: props.disabled || false, + loading: props.loading || false, + closeOnSelect: props.closeOnSelect ?? !props.multiple, + placeholder: props.placeholder || 'Select...', + searchPlaceholder: props.searchPlaceholder || 'Search...', + emptyMessage: props.emptyMessage || 'No options found', + loadingMessage: props.loadingMessage || 'Loading...', + maxHeight: props.maxHeight || 280, + name: props.name || '', + required: props.required || false, + maxSelections: props.maxSelections, + + // State + ...state, + _boundReposition: null as (() => void) | null, + + // Computed + get allOptions(): SelectOption[] { + if (this.groups.length > 0) { + return this.groups.flatMap((g) => g.options); + } + return [...this.options, ...this.asyncOptions]; + }, + + get filteredOptions(): SelectOption[] { + const query = this.searchQuery.toLowerCase().trim(); + if (!query) return this.allOptions; + + return this.allOptions.filter( + (opt) => opt.label.toLowerCase().includes(query) || opt.description?.toLowerCase().includes(query), + ); + }, + + get filteredGroups(): SelectGroup[] { + if (this.groups.length === 0) return []; + + const query = this.searchQuery.toLowerCase().trim(); + if (!query) return this.groups; + + return this.groups + .map((group) => ({ + ...group, + options: group.options.filter( + (opt) => opt.label.toLowerCase().includes(query) || opt.description?.toLowerCase().includes(query), + ), + })) + .filter((group) => group.options.length > 0); + }, + + get hasGroups(): boolean { + return this.groups.length > 0; + }, + + get selectedOptions(): SelectOption[] { + return this.allOptions.filter((opt) => this.selectedValues.has(opt.value)); + }, + + get displayValue(): string { + if (this.selectedValues.size === 0) return this.placeholder; + + if (this.multiple) { + const count = this.selectedValues.size; + if (count === 1) { + const opt = this.selectedOptions[0]; + return opt ? (opt.display_label ?? opt.label) : this.placeholder; + } + return `${count} selected`; + } + + const opt = this.selectedOptions[0]; + return opt ? (opt.display_label ?? opt.label) : this.placeholder; + }, + + get canClear(): boolean { + return this.clearable && this.selectedValues.size > 0 && !this.disabled; + }, + + get isMaxSelectionsReached(): boolean { + return this.multiple && this.maxSelections !== undefined && this.selectedValues.size >= this.maxSelections; + }, + + // Lifecycle + init() { + this.initializeValue(); + this.setupFormIntegration(); + this.setupKeyboardNavigation(); + this.syncHiddenInput(); + this._boundReposition = this.calculateDropdownPosition.bind(this); + }, + + destroy() { + // Cleanup + if (this._boundReposition) { + window.removeEventListener('resize', this._boundReposition); + window.removeEventListener('scroll', this._boundReposition); + } + }, + + // Initialization + initializeValue() { + const initialValue = props.value ?? props.defaultValue; + if (initialValue === undefined || initialValue === null) return; + + if (Array.isArray(initialValue)) { + this.selectedValues = new Set(initialValue); + } else { + this.selectedValues = new Set([initialValue]); + } + }, + + setupFormIntegration() { + if (!this.name) return; + + const $el = (this as unknown as { $el: HTMLElement }).$el; + const formElement = $el.closest('form[x-data*="tutorForm"], form[x-data*="form("]') as HTMLElement; + + if (formElement) { + try { + const alpine = window.Alpine; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alpineData = alpine?.$data(formElement) as FormControlMethods & { values: Record }; + + if (alpineData && typeof alpineData.register === 'function') { + // Build validation rules + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rules: Record = {}; + if (this.required) { + rules.required = this.required; + } + + // Register with form + // eslint-disable-next-line @typescript-eslint/no-explicit-any + alpineData.register(this.name, rules as any); + + // If form already has a value for this field (e.g. from reset() called earlier), sync it TO this component + const formValue = alpineData.values?.[this.name]; + if (formValue !== undefined && formValue !== null && formValue !== '') { + if (Array.isArray(formValue)) { + this.selectedValues = new Set(formValue); + } else { + this.selectedValues = new Set([formValue]); + } + this.syncHiddenInput(); + } else { + // Otherwise, set form value from component's initial state + const currentValue = this.getCurrentValue(); + alpineData.setValue(this.name, currentValue ?? '', { shouldValidate: false }); + } + + // Watch for external form changes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component = this as unknown as { $watch: (path: string, cb: (val: any) => void) => void }; + if (component.$watch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component.$watch(`values.${this.name}`, (newVal: any) => { + const current = this.getCurrentValue(); + const isSame = Array.isArray(newVal) + ? JSON.stringify([...newVal].sort()) === + JSON.stringify(Array.isArray(current) ? [...current].sort() : [current]) + : String(newVal) === String(current); + if (!isSame) { + if (Array.isArray(newVal)) { + this.selectedValues = new Set(newVal); + } else if (newVal === null || newVal === undefined || newVal === '') { + this.selectedValues.clear(); + } else { + this.selectedValues = new Set([newVal]); + } + this.syncHiddenInput(); + } + }); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to integrate with form:', error); + } + } + }, + + setupKeyboardNavigation() { + const $el = (this as unknown as { $el: HTMLElement }).$el; + + $el.addEventListener('keydown', (e: KeyboardEvent) => { + if (this.disabled) return; + this.handleKeyDown(e); + }); + }, + + // Actions + async open() { + if (this.disabled || this.isOpen) return; + + this.isOpen = true; + this.isFocused = true; + + // Call onOpen callback + props.onOpen?.(); + + // Wait for dropdown to render, then calculate position + await this.nextTick(); + this.calculateDropdownPosition(); + + // Listen for resize/scroll to update position + if (this._boundReposition) { + window.addEventListener('resize', this._boundReposition); + window.addEventListener('scroll', this._boundReposition, { passive: true }); + } + + // Focus search input if searchable + if (this.searchable) { + this.focusSearchInput(); + } + + // Scroll to selected option + this.scrollToSelected(); + }, + + close() { + if (!this.isOpen) return; + + this.isOpen = false; + this.searchQuery = ''; + this.highlightedIndex = -1; + + props.onClose?.(); + + if (this._boundReposition) { + window.removeEventListener('resize', this._boundReposition); + window.removeEventListener('scroll', this._boundReposition); + } + }, + + toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + }, + + async selectOption(option: SelectOption) { + if (option.disabled) return; + + if (this.multiple) { + this.toggleMultipleSelection(option); + } else { + this.setSingleSelection(option); + } + + this.notifyChange(); + this.syncHiddenInput(); + this.updateFormValue(); + + if (this.closeOnSelect) { + this.close(); + } + }, + + toggleMultipleSelection(option: SelectOption) { + if (this.selectedValues.has(option.value)) { + this.selectedValues.delete(option.value); + } else { + if (this.isMaxSelectionsReached) return; + this.selectedValues.add(option.value); + } + }, + + setSingleSelection(option: SelectOption) { + this.selectedValues.clear(); + this.selectedValues.add(option.value); + }, + + deselectOption(option: SelectOption, event?: Event) { + event?.stopPropagation(); + + this.selectedValues.delete(option.value); + this.notifyChange(); + this.syncHiddenInput(); + this.updateFormValue(); + }, + + clear(event?: Event) { + event?.stopPropagation(); + + this.selectedValues.clear(); + this.notifyChange(); + this.syncHiddenInput(); + this.updateFormValue(); + }, + + async handleSearch(query: string) { + this.searchQuery = query; + this.highlightedIndex = 0; + + if (props.onSearch) { + this.isLoading = true; + try { + const result = props.onSearch(query); + if (result instanceof Promise) { + const asyncOptions = await result; + this.asyncOptions = asyncOptions || []; + } + } finally { + this.isLoading = false; + } + } + }, + + // Keyboard Navigation + handleKeyDown(event: KeyboardEvent) { + const handlers: Record void> = { + Enter: () => this.handleEnterKey(event), + ' ': () => this.handleSpaceKey(event), + Escape: () => this.close(), + ArrowDown: () => this.handleArrowDown(event), + ArrowUp: () => this.handleArrowUp(event), + Home: () => this.handleHomeKey(event), + End: () => this.handleEndKey(event), + Tab: () => this.handleTabKey(event), + Backspace: () => this.handleBackspaceKey(event), + }; + + const handler = handlers[event.key]; + if (handler) { + handler(); + } + }, + + handleEnterKey(event: Event) { + event.preventDefault(); + + if (!this.isOpen) { + this.open(); + return; + } + + if (this.highlightedIndex >= 0) { + const option = this.filteredOptions[this.highlightedIndex]; + if (option) { + this.selectOption(option); + } + } + }, + + handleSpaceKey(event: Event) { + // Only toggle if not in search input + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT') return; + + event.preventDefault(); + if (!this.isOpen) { + this.open(); + } + }, + + handleArrowDown(event: Event) { + event.preventDefault(); + + if (!this.isOpen) { + this.open(); + return; + } + + this.moveHighlight(1); + }, + + handleArrowUp(event: Event) { + event.preventDefault(); + + if (!this.isOpen) { + this.open(); + return; + } + + this.moveHighlight(-1); + }, + + handleHomeKey(event: Event) { + event.preventDefault(); + this.highlightedIndex = this.findNextEnabledIndex(0, 1); + this.scrollToHighlighted(); + }, + + handleEndKey(event: Event) { + event.preventDefault(); + this.highlightedIndex = this.findNextEnabledIndex(this.filteredOptions.length - 1, -1); + this.scrollToHighlighted(); + }, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handleTabKey(event: Event) { + if (this.isOpen) { + this.close(); + } + }, + + handleBackspaceKey(event: Event) { + // Remove last selected item if search is empty + const target = event.target as HTMLInputElement; + if (target.tagName === 'INPUT' && target.value === '' && this.multiple) { + const lastOption = this.selectedOptions[this.selectedOptions.length - 1]; + if (lastOption) { + this.deselectOption(lastOption); + } + } + }, + + moveHighlight(direction: 1 | -1) { + const options = this.filteredOptions; + if (options.length === 0) return; + + let newIndex = this.highlightedIndex + direction; + newIndex = this.findNextEnabledIndex(newIndex, direction); + + if (newIndex >= 0 && newIndex < options.length) { + this.highlightedIndex = newIndex; + this.scrollToHighlighted(); + } + }, + + findNextEnabledIndex(startIndex: number, direction: 1 | -1): number { + const options = this.filteredOptions; + let index = startIndex; + + while (index >= 0 && index < options.length) { + if (!options[index]?.disabled) { + return index; + } + index += direction; + } + + return -1; + }, + + // Helpers + isSelected(option: SelectOption): boolean { + return this.selectedValues.has(option.value); + }, + + isHighlighted(index: number): boolean { + return this.highlightedIndex === index; + }, + + getCurrentValue(): string | number | (string | number)[] | null { + if (this.selectedValues.size === 0) return null; + + if (this.multiple) { + return Array.from(this.selectedValues); + } + + return Array.from(this.selectedValues)[0]; + }, + + notifyChange() { + const value = this.getCurrentValue(); + if (value !== null) { + props.onChange?.(value); + } + }, + + updateFormValue() { + if (!this.name) return; + + const $el = (this as unknown as { $el: HTMLElement }).$el; + const formElement = $el.closest('form[x-data*="tutorForm"], form[x-data*="form("]') as HTMLElement; + + if (formElement) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alpineData = window.Alpine?.$data(formElement) as any; + + if (alpineData && typeof alpineData.setValue === 'function') { + const value = this.getCurrentValue(); + // Always update, even if null (for required validation) + alpineData.setValue(this.name, value ?? '', { + shouldValidate: true, + shouldTouch: true, + shouldDirty: true, + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to update form value:', error); + } + } + }, + + syncHiddenInput() { + if (!this.name) return; + + // Always get the root select container (not the clicked element) + let $el = (this as unknown as { $el: HTMLElement }).$el; + + // If $el is not the root select container, find it + if (!$el.classList.contains('tutor-select')) { + const rootSelect = $el.closest('.tutor-select') as HTMLElement; + if (rootSelect) { + $el = rootSelect; + } + } + + const value = this.getCurrentValue(); + + if (this.multiple && Array.isArray(value)) { + // Remove ALL existing hidden inputs for this field + $el.querySelectorAll(`input[type="hidden"][name^="${this.name}"]`).forEach((input) => input.remove()); + + // Create hidden input for each value + value.forEach((val, index) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = `${this.name}[${index}]`; + input.value = String(val); + $el.appendChild(input); + }); + } else { + // Single value - remove all existing first, then create one + $el.querySelectorAll(`input[type="hidden"][name="${this.name}"]`).forEach((input) => input.remove()); + + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = this.name; + input.value = value !== null ? String(value) : ''; + $el.appendChild(input); + } + }, + + calculateDropdownPosition() { + const $el = (this as unknown as { $el: HTMLElement }).$el; + + // Find the root select container + let rootEl = $el; + if (!$el.classList.contains('tutor-select')) { + const root = $el.closest('.tutor-select') as HTMLElement; + if (root) rootEl = root; + } + + const trigger = rootEl.querySelector('[data-select-trigger]') as HTMLElement; + if (!trigger) { + return; + } + + const rect = trigger.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - rect.bottom; + const spaceAbove = rect.top; + + // Calculate estimated dropdown height + // Account for: options + search box (if searchable) + padding + const optionHeight = 44; // Height per option + const searchBoxHeight = this.searchable ? 60 : 0; + const padding = 16; + const optionsCount = this.filteredOptions.length || this.allOptions.length; + const estimatedHeight = Math.min(this.maxHeight, optionsCount * optionHeight + searchBoxHeight + padding); + + // Add some buffer space (8px) + const buffer = 8; + + // Prefer bottom, but flip to top if not enough space below AND more space above + if (spaceBelow < estimatedHeight + buffer && spaceAbove > spaceBelow) { + this.dropdownPosition = 'top'; + } else { + this.dropdownPosition = 'bottom'; + } + }, + + async nextTick() { + return new Promise((resolve) => { + const component = this as unknown as { $nextTick?: (cb: () => void) => void }; + if (component.$nextTick) { + component.$nextTick(() => resolve(undefined)); + } else { + setTimeout(() => resolve(undefined), 0); + } + }); + }, + + focusSearchInput() { + const $el = (this as unknown as { $el: HTMLElement }).$el; + const input = $el.querySelector('[data-select-search]') as HTMLInputElement; + if (input) { + input.focus(); + } + }, + + scrollToSelected() { + const selectedIndex = this.filteredOptions.findIndex((opt) => this.isSelected(opt)); + if (selectedIndex >= 0) { + this.highlightedIndex = selectedIndex; + this.scrollToHighlighted(); + } + }, + + scrollToHighlighted() { + this.nextTick().then(() => { + const $el = (this as unknown as { $el: HTMLElement }).$el; + const menu = $el.querySelector('[data-select-menu]'); + const options = menu?.querySelectorAll('[data-select-option]'); + + if (options && this.highlightedIndex >= 0 && this.highlightedIndex < options.length) { + const option = options[this.highlightedIndex] as HTMLElement; + option?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }); + }, + }; +}; + +export const selectMeta: AlpineComponentMeta = { + name: 'select', + component: select, +}; diff --git a/assets/core/ts/components/star-rating.ts b/assets/core/ts/components/star-rating.ts new file mode 100644 index 0000000000..9f61a49799 --- /dev/null +++ b/assets/core/ts/components/star-rating.ts @@ -0,0 +1,58 @@ +import { __ } from '@wordpress/i18n'; + +interface StarRatingConfig { + initialRating?: number; + fieldName: string; +} + +const starRatingInput = (config: StarRatingConfig) => ({ + rating: config.initialRating || 1, + hoverRating: 0, + fieldName: config.fieldName, + + get effectiveRating() { + return this.hoverRating > 0 ? this.hoverRating : this.rating; + }, + + get feedback(): string { + const rating = this.effectiveRating; + if (rating === 0) { + return ''; + } + + const labels: Record = { + 1: __('Poor', 'tutor'), + 2: __('Fair', 'tutor'), + 3: __('Okay', 'tutor'), + 4: __('Good', 'tutor'), + 5: __('Amazing', 'tutor'), + }; + + if (Number.isInteger(rating)) { + return labels[rating] || ''; + } + + // Handle fractional ratings (e.g., 4.5 -> "Good / Amazing") + const lower = Math.floor(rating); + const upper = Math.ceil(rating); + + const lowerLabel = labels[lower]; + const upperLabel = labels[upper]; + + if (lowerLabel && upperLabel) { + return `${lowerLabel} / ${upperLabel}`; + } + + return lowerLabel || upperLabel || ''; + }, + + setRating(val: number, onSet: (rating: number) => void) { + this.rating = val; + onSet(this.rating); + }, +}); + +export const starRatingMeta = { + name: 'starRatingInput', + component: starRatingInput, +}; diff --git a/assets/core/ts/components/statics.ts b/assets/core/ts/components/statics.ts new file mode 100644 index 0000000000..b3f8b9d90d --- /dev/null +++ b/assets/core/ts/components/statics.ts @@ -0,0 +1,231 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; + +type StaticsType = 'progress' | 'complete' | 'locked'; +type StaticsSize = 'x-small' | 'small' | 'medium' | 'large'; + +export interface StaticsProps { + value?: number; + type?: StaticsType; + size?: StaticsSize; + background?: string; + strokeColor?: string; + progressStrokeColor?: string; + showLabel?: boolean; + label?: string; + animated?: boolean; + duration?: number; +} + +const SIZE_CONFIG = { + large: { dimension: 144, strokeWidth: 10.8, iconSizes: { check: 80, lock: 104 } }, + medium: { dimension: 56, strokeWidth: 4.3, iconSizes: { check: 24, lock: 32 } }, + small: { dimension: 44, strokeWidth: 3.3, iconSizes: { check: 24, lock: 32 } }, + 'x-small': { dimension: 16, strokeWidth: 2, iconSizes: { check: 8, lock: 12 } }, +} as const; + +const DEFAULT_CONFIG = { + value: 0, + type: 'progress' as StaticsType, + size: 'small' as StaticsSize, + background: 'none', + strokeColor: 'var(--tutor-actions-brand-secondary)', + progressStrokeColor: 'var(--tutor-actions-brand-primary)', + showLabel: true, + label: '', + animated: false, + duration: 1000, +} as const; + +const ANIMATION_EASING_POWER = 3; +const MAX_PROGRESS = 100; +const MIN_PROGRESS = 0; + +export const statics = (config: StaticsProps) => ({ + value: 0, + targetValue: config.value ?? DEFAULT_CONFIG.value, + type: config.type ?? DEFAULT_CONFIG.type, + size: config.size ?? DEFAULT_CONFIG.size, + background: config.background ?? DEFAULT_CONFIG.background, + strokeColor: config.strokeColor ?? DEFAULT_CONFIG.strokeColor, + progressStrokeColor: config.progressStrokeColor ?? DEFAULT_CONFIG.progressStrokeColor, + showLabel: config.showLabel ?? DEFAULT_CONFIG.showLabel, + label: config.label ?? DEFAULT_CONFIG.label, + animated: config.animated ?? DEFAULT_CONFIG.animated, + duration: config.duration ?? DEFAULT_CONFIG.duration, + + init() { + this.initializeValue(); + }, + + initializeValue() { + if (this.animated && this.type === 'progress') { + this.animateProgress(); + } else { + this.value = this.targetValue; + } + }, + + animateProgress() { + const startTime = Date.now(); + const startValue = this.value; + const endValue = this.targetValue; + + const animate = () => { + const elapsedTime = Date.now() - startTime; + const progress = Math.min(elapsedTime / this.duration, 1); + const easedProgress = this.easeOut(progress); + + this.value = startValue + (endValue - startValue) * easedProgress; + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + this.value = endValue; + } + }; + + requestAnimationFrame(animate); + }, + + easeOut(progress: number): number { + return 1 - Math.pow(1 - progress, ANIMATION_EASING_POWER); + }, + + sizeConfig() { + const size = this.size ?? DEFAULT_CONFIG.size; + return SIZE_CONFIG[size as StaticsSize] ?? SIZE_CONFIG[DEFAULT_CONFIG.size]; + }, + + sizeValue(): number { + return this.sizeConfig().dimension; + }, + + strokeWidth(): number { + return this.sizeConfig().strokeWidth; + }, + + radius(): number { + return (this.sizeValue() - this.strokeWidth()) / 2; + }, + + center(): number { + return this.sizeValue() / 2; + }, + + viewBox(): string { + return `0 0 ${this.sizeValue()} ${this.sizeValue()}`; + }, + + circumference(): number { + return 2 * Math.PI * this.radius(); + }, + + strokeDashoffset(): number { + const clampedProgress = Math.min(Math.max(this.value, MIN_PROGRESS), MAX_PROGRESS); + return this.circumference() - (clampedProgress / MAX_PROGRESS) * this.circumference(); + }, + + displayValue(): number { + return Math.round(this.value); + }, + + displayLabel(): string { + return this.label || `${this.displayValue()}%`; + }, + + get labelText(): string { + if (!this.showLabel) return ''; + return this.displayValue() === 0 ? '0' : `${this.displayValue()}%`; + }, + + get labelClass(): string { + const baseClass = 'tutor-statics-progress-label'; + const sizeClass = this.size === 'large' ? 'tutor-statics-progress-label-large' : ''; + return `${baseClass} ${sizeClass}`.trim(); + }, + + renderProgressCircle(): string { + return ` + + ${this.renderBackgroundCircle()} + ${this.renderProgressArc()} + + ${this.renderLabel()} + `; + }, + + renderBackgroundCircle(): string { + return ` + + `; + }, + + renderProgressArc(): string { + return ` + + `; + }, + + renderLabel(): string { + if (!this.showLabel) return ''; + return `
${this.labelText}
`; + }, + + renderCompleteCircle(): string { + const iconSize = this.sizeConfig().iconSizes.check; + return this.renderIconContainer('tutor-statics-complete', 'checkStroke', iconSize); + }, + + renderLockIcon(): string { + const iconSize = this.sizeConfig().iconSizes.lock; + return this.renderIconContainer('tutor-statics-locked', 'circumLock', iconSize); + }, + + renderIconContainer(className: string, iconName: string, iconSize: number): string { + return ` +
+
+
+ `; + }, + + render(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $el = (this as any).$el as HTMLElement; + $el.classList.add('tutor-statics'); + + switch (this.type) { + case 'progress': + return this.renderProgressCircle(); + case 'complete': + return this.renderCompleteCircle(); + case 'locked': + return this.renderLockIcon(); + default: + return ''; + } + }, +}); + +export const staticsMeta: AlpineComponentMeta = { + name: 'statics', + component: statics, +}; diff --git a/assets/core/ts/components/status-select.ts b/assets/core/ts/components/status-select.ts new file mode 100644 index 0000000000..504d5cebc6 --- /dev/null +++ b/assets/core/ts/components/status-select.ts @@ -0,0 +1,88 @@ +import { __ } from '@wordpress/i18n'; + +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { convertToErrorMessage } from '@Core/ts/utils/error'; + +import { tutorConfig } from '@TutorShared/config/config'; + +export interface StatusSelectProps { + selected: string; + action: string; + data: Record; + variants: Record; +} + +export const statusSelect = (props: StatusSelectProps) => { + return { + selectedValue: props.selected, + prevValue: props.selected, + isLoading: false, + variants: props.variants, + + get currentVariant() { + return this.variants[this.selectedValue] || 'default'; + }, + + get variantClasses() { + const classes: Record = {}; + Object.values(this.variants).forEach((variant) => { + classes[`tutor-status-select-${variant}`] = variant === this.currentVariant; + }); + + // Handle the default variant if not explicitly in variants list + if (!Object.values(this.variants).includes('default')) { + classes['tutor-status-select-default'] = this.currentVariant === 'default'; + } + + return classes; + }, + + async updateStatus() { + if (this.selectedValue === this.prevValue || !props.action) { + return; + } + + this.isLoading = true; + + try { + const formData = new FormData(); + + // Add basic nonce and action + formData.append(tutorConfig.nonce_key, tutorConfig._tutor_nonce); + formData.append('action', props.action); + formData.append('status', this.selectedValue); + + // Add additional data + for (const [key, value] of Object.entries(props.data)) { + formData.append(key, value); + } + + const response = await fetch(tutorConfig.ajaxurl, { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (result.success) { + this.prevValue = this.selectedValue; + window.TutorCore.toast.success(result.data?.message || __('Status updated successfully', 'tutor')); + } else { + this.selectedValue = this.prevValue; + window.TutorCore.toast.error(convertToErrorMessage(result)); + } + } catch (error) { + this.selectedValue = this.prevValue; + // eslint-disable-next-line no-console + console.error('Status update error:', error); + } finally { + this.isLoading = false; + } + }, + }; +}; + +export const statusSelectMeta: AlpineComponentMeta = { + name: 'statusSelect', + component: statusSelect, +}; diff --git a/assets/core/ts/components/tabs.ts b/assets/core/ts/components/tabs.ts new file mode 100644 index 0000000000..57cd1a19ba --- /dev/null +++ b/assets/core/ts/components/tabs.ts @@ -0,0 +1,107 @@ +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; + +export interface TabItem { + id: string; + label: string; + icon?: string; + disabled?: boolean; + href?: string; +} + +export interface TabsConfig { + tabs: TabItem[]; + defaultTab?: string; + orientation?: 'horizontal' | 'vertical'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; + onChange?: (tabId: string) => void; + urlParams?: { + enabled?: boolean; + paramName?: string; + }; +} + +export const tabs = (config: TabsConfig) => ({ + tabs: config.tabs, + activeTab: config.defaultTab || config.tabs[0]?.id || '', + orientation: config.orientation || 'horizontal', + size: config.size || 'lg', + urlParamsConfig: { + enabled: config.urlParams?.enabled ?? true, + paramName: config.urlParams?.paramName || 'page_tab', + }, + + async init() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $el = (this as any).$el as HTMLElement; + + let initialTab = this.activeTab; + + // Only read from URL if URL params are enabled + if (this.urlParamsConfig.enabled) { + const url = new URL(window.location.href); + const tabId = url.searchParams.get(this.urlParamsConfig.paramName); + if (tabId) { + initialTab = tabId; + } + } + + this.selectTab(initialTab); + $el.classList.add('tutor-tabs-' + this.orientation); + }, + + selectTab(tabId: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $dispatch = (this as any).$dispatch; + const tab = this.tabs.find((t) => t.id === tabId); + + if (!tab || tab.disabled) { + return; + } + + if (tab.href) { + window.location.href = tab.href; + return; + } + + this.activeTab = tabId; + + if (this.urlParamsConfig.enabled) { + const url = new URL(window.location.href); + url.searchParams.set(this.urlParamsConfig.paramName, tabId); + window.history.replaceState({}, '', url.toString()); + } + + if (config.onChange) { + config.onChange(tabId); + } + + // Dispatch custom event + $dispatch(TUTOR_CUSTOM_EVENTS.TAB_CHANGE, { tabId, tab }); + }, + + isActive(tabId: string): boolean { + return this.activeTab === tabId; + }, + + getTabClass(tab: TabItem) { + const classes = ['tutor-tabs-tab']; + if (this.size === 'sm') { + classes.push('tutor-tabs-tab-sm'); + } + + if (this.size === 'md') { + classes.push('tutor-tabs-tab-md'); + } + + if (this.isActive(tab.id)) { + classes.push('tutor-tabs-tab-active'); + } + return classes.join(' '); + }, +}); + +export const tabsMeta = { + name: 'tabs', + component: tabs, +}; diff --git a/assets/core/ts/components/time-input.ts b/assets/core/ts/components/time-input.ts new file mode 100644 index 0000000000..468085c67d --- /dev/null +++ b/assets/core/ts/components/time-input.ts @@ -0,0 +1,447 @@ +import { __ } from '@wordpress/i18n'; +import dayjs from 'dayjs'; + +import { type FormControlMethods, type ValidationRules } from '@Core/ts/components/form'; +import { popover, type PopoverProps } from '@Core/ts/components/popover'; +import { DateFormats } from '@Core/ts/date-formats'; +import { type AlpineComponentMeta } from '@Core/ts/types'; + +export interface TimeInputProps extends PopoverProps { + value?: string; + defaultValue?: string; + interval?: number; + placeholder?: string; + disabled?: boolean; + clearable?: boolean; + name?: string; + required?: boolean | string; + onChange?: (value: string) => void; +} + +interface TimeInputState { + highlightedIndex: number; + value: string; + options: string[]; +} + +const defaultProps: Required< + Pick +> = { + interval: 30, + placeholder: __('Select time', 'tutor'), + disabled: false, + clearable: true, + name: '', + required: false, +}; + +const buildTimeOptions = (interval: number): string[] => { + const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : defaultProps.interval; + const start = dayjs().hour(0).minute(0); + const end = dayjs().hour(23).minute(59); + + const options: string[] = []; + let current = start; + while (current.isBefore(end) || current.isSame(end, 'minute')) { + options.push(current.format(DateFormats.hoursMinutes)); + current = current.add(safeInterval, 'minute'); + } + return options; +}; + +const normalizeTimeValue = (value: string): string => { + const raw = String(value || '') + .replace(/\u00a0/g, ' ') + .trim(); + + const match = raw.match(/^(\d{1,2}):(\d{2})\s*([AaPp][Mm])$/); + if (match) { + const hour = String(Math.min(Math.max(parseInt(match[1], 10), 1), 12)).padStart(2, '0'); + const minute = match[2]; + const meridiem = match[3].toUpperCase(); + return `${hour}:${minute} ${meridiem}`; + } + + return raw.toUpperCase().replace(/\s+/g, ' '); +}; + +export const timeInput = (props: TimeInputProps = {}) => { + const popoverInstance = popover({ + placement: props.placement || 'bottom-start', + offset: props.offset ?? 4, + onShow: props.onShow, + onHide: props.onHide, + }); + + const state: TimeInputState = { + highlightedIndex: -1, + value: String(props.value ?? props.defaultValue ?? ''), + options: buildTimeOptions(props.interval ?? defaultProps.interval), + }; + const popoverUpdatePosition = popoverInstance.updatePosition; + const popoverInit = popoverInstance.init; + const popoverDestroy = popoverInstance.destroy; + + return { + ...popoverInstance, + ...state, + interval: props.interval ?? defaultProps.interval, + placeholder: props.placeholder ?? defaultProps.placeholder, + disabled: props.disabled ?? defaultProps.disabled, + clearable: props.clearable ?? defaultProps.clearable, + name: props.name ?? defaultProps.name, + required: props.required ?? defaultProps.required, + onChange: props.onChange, + + init() { + popoverInit.call(this); + const matchingOption = this.getMatchingOption(this.value); + if (matchingOption) { + this.value = matchingOption; + } + this.setupFormIntegration(); + this.syncHiddenInput(); + }, + + destroy() { + popoverDestroy.call(this); + }, + + show() { + this.open = true; + + const afterShow = () => { + this.updatePosition(); + if (props.onShow) { + props.onShow(); + } + }; + + const component = this as unknown as { $nextTick?: (callback: () => void) => void }; + if (component.$nextTick) { + component.$nextTick(afterShow); + } else { + requestAnimationFrame(afterShow); + } + }, + + hide() { + this.open = false; + if (props.onHide) { + props.onHide(); + } + }, + + updatePosition() { + this.syncPopoverWidth(); + popoverUpdatePosition.call(this); + }, + + get hasValue(): boolean { + return this.value !== ''; + }, + + get displayValue(): string { + return this.value || this.placeholder; + }, + + get canClear(): boolean { + return this.clearable && this.hasValue && !this.disabled; + }, + + openDropdown() { + if (this.disabled) return; + this.show(); + this.syncHighlightedIndex(); + this.scrollToHighlighted(); + }, + + closeDropdown() { + this.hide(); + this.highlightedIndex = -1; + }, + + toggleDropdown() { + if (this.disabled) return; + if (this.open) { + this.closeDropdown(); + } else { + this.openDropdown(); + } + }, + + syncHighlightedIndex() { + const selectedIndex = this.getSelectedIndex(); + this.highlightedIndex = selectedIndex >= 0 ? selectedIndex : 0; + }, + + getSelectedIndex(): number { + const matchingOption = this.getMatchingOption(this.value); + if (!matchingOption) return -1; + return this.options.findIndex((option) => option === matchingOption); + }, + + getMatchingOption(value: string): string | null { + const normalizedValue = normalizeTimeValue(value); + if (!normalizedValue) return null; + + const option = this.options.find((item) => normalizeTimeValue(item) === normalizedValue); + return option || null; + }, + + selectOption(option: string) { + this.value = option; + this.syncHiddenInput(); + this.syncFormValue(); + if (this.onChange) { + this.onChange(option); + } + this.closeDropdown(); + }, + + clearValue() { + if (!this.canClear) return; + this.value = ''; + this.syncHiddenInput(); + this.syncFormValue(); + if (this.onChange) { + this.onChange(''); + } + }, + + onInputChange(event: Event) { + if (this.disabled) return; + const target = event.target as HTMLInputElement; + this.value = target.value; + + const matchingOption = this.getMatchingOption(this.value); + if (matchingOption) { + this.value = matchingOption; + this.highlightedIndex = this.options.findIndex((option) => option === matchingOption); + } else { + this.highlightedIndex = -1; + } + + this.syncHiddenInput(); + this.syncFormValue(); + + if (this.open && this.highlightedIndex >= 0) { + this.scrollToHighlighted(); + } + + if (this.onChange) { + this.onChange(this.value); + } + }, + + onInputKeydown(event: KeyboardEvent) { + if (this.disabled) return; + + if (event.key === 'Enter') { + event.preventDefault(); + if (this.open) { + const highlightedOption = this.options[this.highlightedIndex]; + if (highlightedOption) { + this.selectOption(highlightedOption); + } else { + this.closeDropdown(); + } + } else { + this.openDropdown(); + } + return; + } + + if (event.key === 'Escape' && this.open) { + event.preventDefault(); + this.closeDropdown(); + return; + } + + if (event.key === 'Tab') { + this.closeDropdown(); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!this.open) { + this.openDropdown(); + } else { + this.moveHighlight(1); + } + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!this.open) { + this.openDropdown(); + } else { + this.moveHighlight(-1); + } + } + }, + + onListKeydown(event: KeyboardEvent) { + if (this.disabled) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.moveHighlight(1); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.moveHighlight(-1); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + const highlightedOption = this.options[this.highlightedIndex]; + if (highlightedOption) { + this.selectOption(highlightedOption); + } + } + }, + + moveHighlight(direction: 1 | -1) { + if (this.options.length === 0) return; + + let nextIndex = this.highlightedIndex; + if (nextIndex < 0) { + nextIndex = direction === 1 ? 0 : this.options.length - 1; + } else { + nextIndex += direction; + } + + if (nextIndex < 0) { + nextIndex = 0; + } else if (nextIndex >= this.options.length) { + nextIndex = this.options.length - 1; + } + + this.highlightedIndex = nextIndex; + this.scrollToHighlighted(); + }, + + scrollToHighlighted() { + const component = this as unknown as { + $nextTick?: (callback: () => void) => void; + $refs: Record; + }; + const scroll = () => { + const content = component.$refs.content; + const option = content?.querySelector(`[data-option-index="${this.highlightedIndex}"]`) as HTMLElement | null; + option?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }; + + if (component.$nextTick) { + component.$nextTick(scroll); + } else { + setTimeout(scroll, 0); + } + }, + + syncPopoverWidth() { + const component = this as unknown as { $refs: { trigger?: HTMLElement; content?: HTMLElement } }; + const trigger = component.$refs.trigger; + const content = component.$refs.content; + if (!trigger || !content) return; + + const triggerRect = trigger.getBoundingClientRect(); + content.style.width = `${triggerRect.width}px`; + content.style.minWidth = `${triggerRect.width}px`; + }, + + syncHiddenInput() { + if (!this.name) return; + const $el = (this as unknown as { $el: HTMLElement }).$el; + let hiddenInput = $el.querySelector(`input[type="hidden"][name="${this.name}"]`) as HTMLInputElement | null; + + if (!hiddenInput) { + hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = this.name; + $el.appendChild(hiddenInput); + } + + hiddenInput.value = this.value; + }, + + syncFormValue() { + if (!this.name) return; + const $el = (this as unknown as { $el: HTMLElement }).$el; + const formElement = $el.closest('form[x-data*="tutorForm"], form[x-data*="form("]') as HTMLElement | null; + if (!formElement) return; + + const alpineData = window.Alpine?.$data(formElement) as FormControlMethods | undefined; + if (alpineData && typeof alpineData.setValue === 'function') { + alpineData.setValue(this.name, this.value, { shouldValidate: true }); + } + }, + + setupFormIntegration() { + if (!this.name) return; + const $el = (this as unknown as { $el: HTMLElement }).$el; + const formElement = $el.closest('form[x-data*="tutorForm"], form[x-data*="form("]') as HTMLElement | null; + + if (!formElement) return; + + try { + const alpineData = window.Alpine?.$data(formElement) as + | (FormControlMethods & { values: Record }) + | undefined; + if (!alpineData || typeof alpineData.register !== 'function') { + return; + } + + const rules: ValidationRules = { + numberOnly: false, + validTime: true, + }; + + if (this.required) { + rules.required = this.required; + } + + alpineData.register(this.name, rules); + + const externalValue = alpineData.values?.[this.name]; + if (externalValue !== undefined && externalValue !== null && externalValue !== '') { + const matchingOption = this.getMatchingOption(String(externalValue)); + this.value = matchingOption || String(externalValue); + this.syncHiddenInput(); + } else { + alpineData.setValue(this.name, this.value, { shouldValidate: false }); + } + + const component = this as unknown as { $watch?: (path: string, cb: (value: unknown) => void) => void }; + component.$watch?.(`values['${this.name}']`, (newValue: unknown) => { + const normalized = newValue === null || newValue === undefined ? '' : String(newValue); + const matchingOption = this.getMatchingOption(normalized); + const nextValue = matchingOption || normalized; + + if (nextValue !== this.value) { + this.value = nextValue; + this.syncHiddenInput(); + this.highlightedIndex = this.getSelectedIndex(); + if (this.open && this.highlightedIndex >= 0) { + this.scrollToHighlighted(); + } + } + }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('timeInput form integration failed', error); + } + }, + }; +}; + +export const timeInputMeta: AlpineComponentMeta = { + name: 'timeInput', + component: timeInput, +}; diff --git a/assets/core/ts/components/toast.ts b/assets/core/ts/components/toast.ts new file mode 100644 index 0000000000..1cab587266 --- /dev/null +++ b/assets/core/ts/components/toast.ts @@ -0,0 +1,44 @@ +import { toastServiceMeta } from '@Core/ts/services/toast/Toast'; +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { type ToastConfig } from '@Core/ts/types/toast'; + +const toast = () => { + return { + show(message: string, config: ToastConfig = {}): string { + return toastServiceMeta.instance.show(message, config); + }, + + remove(id: string): void { + toastServiceMeta.instance.dismiss(id); + }, + + clear(): void { + toastServiceMeta.instance.clear(); + }, + + dismiss(id?: string): void { + toastServiceMeta.instance.dismiss(id); + }, + + success(message: string, duration?: number): string { + return toastServiceMeta.instance.success(message, duration); + }, + + error(message: string, duration?: number): string { + return toastServiceMeta.instance.error(message, duration); + }, + + warning(message: string, duration?: number): string { + return toastServiceMeta.instance.warning(message, duration); + }, + + info(message: string, duration?: number): string { + return toastServiceMeta.instance.info(message, duration); + }, + }; +}; + +export const toastMeta: AlpineComponentMeta = { + name: 'toast', + component: toast, +}; diff --git a/assets/core/ts/components/tooltip.ts b/assets/core/ts/components/tooltip.ts new file mode 100644 index 0000000000..cbd99abd01 --- /dev/null +++ b/assets/core/ts/components/tooltip.ts @@ -0,0 +1,351 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; +import { isRTL } from '@Core/ts/utils/util'; + +const TOOLTIP_PLACEMENTS = { + TOP: 'top', + BOTTOM: 'bottom', + START: 'start', + END: 'end', +} as const; + +const TOOLTIP_TRIGGERS = { + HOVER: 'hover', + FOCUS: 'focus', + CLICK: 'click', +} as const; + +const TOOLTIP_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', +} as const; + +const TOOLTIP_ARROWS = { + START: 'start', + CENTER: 'center', + END: 'end', +} as const; + +const DEFAULT_TOOLTIP_OFFSET = 8; + +export interface TooltipProps { + placement?: (typeof TOOLTIP_PLACEMENTS)[keyof typeof TOOLTIP_PLACEMENTS]; + trigger?: (typeof TOOLTIP_TRIGGERS)[keyof typeof TOOLTIP_TRIGGERS]; + size?: (typeof TOOLTIP_SIZES)[keyof typeof TOOLTIP_SIZES]; + arrow?: (typeof TOOLTIP_ARROWS)[keyof typeof TOOLTIP_ARROWS]; + offset?: number; + delay?: { + show?: number; + hide?: number; + }; +} + +const defaultProps: Required = { + placement: TOOLTIP_PLACEMENTS.TOP, + trigger: TOOLTIP_TRIGGERS.HOVER, + size: TOOLTIP_SIZES.SMALL, + arrow: TOOLTIP_ARROWS.START, + offset: DEFAULT_TOOLTIP_OFFSET, + delay: { show: 0, hide: 0 }, +}; + +export const tooltip = (props: TooltipProps = {}) => { + const config = { ...defaultProps, ...props }; + + return { + open: false, + placement: config.placement, + trigger: config.trigger, + size: config.size, + arrow: config.arrow, + offset: config.offset, + delay: config.delay, + actualPlacement: '', + $nextTick: undefined as ((callback: () => void) => void) | undefined, + $el: undefined as HTMLElement | undefined, + $refs: {} as { trigger: HTMLElement; content: HTMLElement }, + + scrollHandler: null as (() => void) | null, + resizeHandler: null as (() => void) | null, + + init() { + this.actualPlacement = this.getActualPlacement(); + this.setupAccessibility(); + this.setupTriggers(); + this.setupEventListeners(); + }, + + destroy() { + this.removeEventListeners(); + }, + + setupEventListeners() { + this.scrollHandler = () => { + if (this.open) { + this.updatePosition(); + } + }; + + this.resizeHandler = () => { + if (this.open) { + this.updatePosition(); + } + }; + + window.addEventListener('scroll', this.scrollHandler, true); + window.addEventListener('resize', this.resizeHandler); + }, + + removeEventListeners() { + if (this.scrollHandler) { + window.removeEventListener('scroll', this.scrollHandler, true); + } + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + }, + + getActualPlacement() { + return this.placement; + }, + + setupTriggers() { + // If trigger is not explicitly defined via x-ref, use $el as trigger + const trigger = this.$refs.trigger || this.$el; + if (!trigger) return; + + if (this.trigger === TOOLTIP_TRIGGERS.HOVER) { + trigger.addEventListener('mouseenter', () => this.showWithDelay()); + trigger.addEventListener('mouseleave', () => this.hideWithDelay()); + } else if (this.trigger === TOOLTIP_TRIGGERS.FOCUS) { + trigger.addEventListener('focus', () => this.show()); + trigger.addEventListener('blur', () => this.hide()); + } else if (this.trigger === TOOLTIP_TRIGGERS.CLICK) { + trigger.addEventListener('click', () => this.toggle()); + } + + trigger.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.open) { + this.hide(); + } + }); + }, + + showWithDelay() { + const delay = this.delay.show || 0; + if (delay) { + setTimeout(() => { + if (!this.open) this.show(); + }, delay); + } else { + this.show(); + } + }, + + hideWithDelay() { + const delay = this.delay.hide || 0; + if (delay) { + setTimeout(() => { + if (this.open) this.hide(); + }, delay); + } else { + this.hide(); + } + }, + + show() { + const content = this.$refs.content; + if (content) { + content.style.visibility = 'hidden'; + } + + this.open = true; + + const afterShow = () => { + this.updatePosition(); + if (content) { + content.style.visibility = 'visible'; + } + requestAnimationFrame(() => this.updatePosition()); + }; + + if (this.$nextTick) { + this.$nextTick(() => { + this.updatePosition(); + afterShow(); + }); + } else { + requestAnimationFrame(afterShow); + } + }, + + hide() { + this.open = false; + const content = this.$refs.content; + if (content) { + content.style.visibility = 'hidden'; + } + }, + + toggle() { + if (this.open) { + this.hide(); + } else { + this.show(); + } + }, + + setupAccessibility() { + const trigger = this.$refs.trigger || this.$el; + const content = this.$refs.content; + + if (trigger && content) { + const tooltipId = `tooltip-${Date.now()}`; + content.setAttribute('id', tooltipId); + content.setAttribute('role', 'tooltip'); + trigger.setAttribute('aria-describedby', tooltipId); + } + }, + + updatePosition() { + const trigger = this.$refs.trigger || (this.$el as HTMLElement); + const content = this.$refs.content as HTMLElement; + + if (!trigger || !content) return; + + // Ensure fixed position before measurement to avoid container constraints + content.style.position = 'fixed'; + + // Temporarily reset transforms/transitions for accurate measurement + const originalTransform = content.style.transform; + const originalTransition = content.style.transition; + content.style.transform = 'none'; + content.style.transition = 'none'; + + // Force layout if display is none (though it should be open now) + const originalDisplay = content.style.display; + if (window.getComputedStyle(content).display === 'none') { + content.style.display = 'block'; + } + + const triggerRect = trigger.getBoundingClientRect(); + const contentWidth = content.offsetWidth; + const contentHeight = content.offsetHeight; + + // Restore styles + content.style.display = originalDisplay; + content.style.transform = originalTransform; + content.style.transition = originalTransition; + + // If measurement failed (0 size), retry on next frame + if (contentWidth === 0 && contentHeight === 0 && this.open) { + requestAnimationFrame(() => this.updatePosition()); + return; + } + + const viewport = { + width: window.innerWidth, + height: window.innerHeight, + }; + + let top = 0; + let left = 0; + let actualPlacement = this.placement; + + // Smart positioning with flipping + if (this.placement === TOOLTIP_PLACEMENTS.TOP) { + const spaceAbove = triggerRect.top; + const spaceBelow = viewport.height - triggerRect.bottom; + if (spaceAbove < contentHeight + this.offset && spaceBelow > spaceAbove) { + actualPlacement = TOOLTIP_PLACEMENTS.BOTTOM; + } + } else if (this.placement === TOOLTIP_PLACEMENTS.BOTTOM) { + const spaceBelow = viewport.height - triggerRect.bottom; + const spaceAbove = triggerRect.top; + if (spaceBelow < contentHeight + this.offset && spaceAbove > spaceBelow) { + actualPlacement = TOOLTIP_PLACEMENTS.TOP; + } + } else if (this.placement === TOOLTIP_PLACEMENTS.START) { + const spaceStart = isRTL ? viewport.width - triggerRect.right : triggerRect.left; + const spaceEnd = isRTL ? triggerRect.left : viewport.width - triggerRect.right; + if (spaceStart < contentWidth + this.offset && spaceEnd > spaceStart) { + actualPlacement = TOOLTIP_PLACEMENTS.END; + } + } else if (this.placement === TOOLTIP_PLACEMENTS.END) { + const spaceEnd = isRTL ? triggerRect.left : viewport.width - triggerRect.right; + const spaceStart = isRTL ? viewport.width - triggerRect.right : triggerRect.left; + if (spaceEnd < contentWidth + this.offset && spaceStart > spaceEnd) { + actualPlacement = TOOLTIP_PLACEMENTS.START; + } + } + + this.actualPlacement = actualPlacement; + + switch (actualPlacement) { + case TOOLTIP_PLACEMENTS.TOP: + top = triggerRect.top - contentHeight - this.offset; + left = triggerRect.left + (triggerRect.width - contentWidth) / 2; + break; + case TOOLTIP_PLACEMENTS.BOTTOM: + top = triggerRect.bottom + this.offset; + left = triggerRect.left + (triggerRect.width - contentWidth) / 2; + break; + case TOOLTIP_PLACEMENTS.START: + top = triggerRect.top + (triggerRect.height - contentHeight) / 2; + if (!isRTL) { + left = triggerRect.left - contentWidth - this.offset; + } else { + left = triggerRect.right + this.offset; + } + break; + case TOOLTIP_PLACEMENTS.END: + top = triggerRect.top + (triggerRect.height - contentHeight) / 2; + if (!isRTL) { + left = triggerRect.right + this.offset; + } else { + left = triggerRect.left - contentWidth - this.offset; + } + break; + } + + // Keep within viewport + top = Math.max(8, Math.min(top, viewport.height - contentHeight - 8)); + left = Math.max(8, Math.min(left, viewport.width - contentWidth - 8)); + + content.style.position = 'fixed'; + content.style.top = `${top}px`; + content.style.left = `${left}px`; + content.style.zIndex = '1070'; + + this.updatePlacementClasses(content, actualPlacement); + }, + + updatePlacementClasses(content: HTMLElement, placement: string) { + const placementClasses = [ + 'tutor-tooltip-top', + 'tutor-tooltip-bottom', + 'tutor-tooltip-start', + 'tutor-tooltip-end', + ]; + const sizeClasses = ['tutor-tooltip-medium', 'tutor-tooltip-large']; + const arrowClasses = ['tutor-tooltip-arrow-start', 'tutor-tooltip-arrow-center', 'tutor-tooltip-arrow-end']; + + content.classList.remove(...placementClasses, ...sizeClasses, ...arrowClasses); + + content.classList.add(`tutor-tooltip-${placement}`); + + if (this.size === TOOLTIP_SIZES.MEDIUM) { + content.classList.add('tutor-tooltip-medium'); + } else if (this.size === TOOLTIP_SIZES.LARGE) { + content.classList.add('tutor-tooltip-large'); + } + + content.classList.add(`tutor-tooltip-arrow-${this.arrow}`); + }, + }; +}; + +export const tooltipMeta: AlpineComponentMeta = { + name: 'tooltip', + component: tooltip, +}; diff --git a/assets/core/ts/components/wp-editor.ts b/assets/core/ts/components/wp-editor.ts new file mode 100644 index 0000000000..950c1ff9db --- /dev/null +++ b/assets/core/ts/components/wp-editor.ts @@ -0,0 +1,303 @@ +import { type AlpineComponentMeta } from '@Core/ts/types'; + +import { TUTOR_CUSTOM_EVENTS } from '../constant'; + +interface TinyMCEEditor { + getContent(): string; + setContent(content: string): void; + on(event: string, callback: () => void): void; + isHidden(): boolean; + settings: { + placeholder?: string; + }; +} + +interface WPEditorConfig { + name: string; + editorId: string; + placeholder?: string; +} + +interface AlpineComponent { + $el: HTMLElement; +} + +interface WPEditorComponent { + name: string; + editorId: string; + placeholder: string; + editorInstance: TinyMCEEditor | null; + isVisualMode: boolean; + initialized: boolean; + init(this: AlpineComponent & WPEditorComponent): void; + setupTinyMCE(this: AlpineComponent & WPEditorComponent): void; + setupTextarea(this: AlpineComponent & WPEditorComponent): void; + setupQuickTags(this: AlpineComponent & WPEditorComponent): void; + setupFormResetListener(this: AlpineComponent & WPEditorComponent): void; + syncEditorToForm(this: AlpineComponent & WPEditorComponent): void; + syncTextareaToForm(this: AlpineComponent & WPEditorComponent): void; + updateFormValue(this: AlpineComponent & WPEditorComponent, content: string): void; + triggerBlur(this: AlpineComponent & WPEditorComponent): void; + getContent(this: AlpineComponent & WPEditorComponent): string; + setContent(this: AlpineComponent & WPEditorComponent, content: string): void; +} + +export const wpEditor = (config: WPEditorConfig): WPEditorComponent => { + const { name, editorId, placeholder = '' } = config; + + return { + name, + editorId, + placeholder, + editorInstance: null as TinyMCEEditor | null, + isVisualMode: true, + initialized: false, + + init(this) { + if (this.initialized) { + return; + } + + // Wait for TinyMCE to be ready + if (typeof window.tinymce !== 'undefined') { + this.setupTinyMCE(); + } else { + // Fallback to textarea if TinyMCE is not available + this.setupTextarea(); + } + + // Setup QuickTags if available + if (typeof window.quicktags !== 'undefined') { + this.setupQuickTags(); + } + + // Listen for form reset events + this.setupFormResetListener(); + + this.initialized = true; + }, + + setupTinyMCE(this: AlpineComponent & WPEditorComponent) { + // TinyMCE might not be initialized immediately + const checkEditor = () => { + // Use editorId instead of name to support multiple editors + const editor = window.tinymce?.get(this.editorId); + + if (editor) { + this.editorInstance = editor; + this.isVisualMode = !editor.isHidden(); + + // Set placeholder if provided + if (this.placeholder) { + editor.settings.placeholder = this.placeholder; + } + + // Sync editor content with form value on change + editor.on('change keyup', () => { + this.syncEditorToForm(); + }); + + // Sync on blur for validation + editor.on('blur', () => { + this.syncEditorToForm(); + this.triggerBlur(); + }); + + // Sync when switching between Visual/Text modes + editor.on('hide', () => { + this.isVisualMode = false; + }); + + editor.on('show', () => { + this.isVisualMode = true; + this.syncEditorToForm(); + }); + + // Dispatch custom focus event + editor.on('focus', () => { + this.$el.dispatchEvent(new CustomEvent(TUTOR_CUSTOM_EVENTS.WP_EDITOR_FOCUS, { bubbles: true })); + }); + + // Handle Cmd/Ctrl + Enter for form submission + editor.on('keydown', (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.keyCode === 13) { + e.preventDefault(); + this.syncEditorToForm(); + const formEl = this.$el.closest('form'); + if (formEl) { + formEl.requestSubmit(); + } + } + }); + + // Sync theme to iframe + const setupThemeSync = () => { + const syncTheme = () => { + const iframeDoc = editor.getDoc && editor.getDoc(); + if (!iframeDoc || !iframeDoc.body) return; + + const theme = document.documentElement.getAttribute('data-tutor-theme') || 'light'; + iframeDoc.documentElement.setAttribute('data-tutor-theme', theme); + + const body = iframeDoc.body; + const computed = window.getComputedStyle(document.body); + const textColor = computed.getPropertyValue('--tutor-text-primary').trim(); + + body.style.backgroundColor = 'transparent'; + body.style.color = textColor; + }; + + syncTheme(); + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'data-tutor-theme') { + syncTheme(); + } + }); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-tutor-theme'] }); + }; + + if (editor.initialized) { + setupThemeSync(); + } else { + editor.on('init', setupThemeSync); + } + } else { + // Retry after a short delay + setTimeout(checkEditor, 100); + } + }; + + // Start checking for editor + checkEditor(); + }, + + setupTextarea(this: AlpineComponent & WPEditorComponent) { + const textarea = document.getElementById(this.editorId) as HTMLTextAreaElement; + + if (textarea) { + // Set placeholder + if (this.placeholder) { + textarea.placeholder = this.placeholder; + } + + // Sync textarea content with form value + textarea.addEventListener('input', () => { + this.syncTextareaToForm(); + }); + + textarea.addEventListener('blur', () => { + this.syncTextareaToForm(); + this.triggerBlur(); + }); + + // Dispatch custom focus event + textarea.addEventListener('focus', () => { + this.$el.dispatchEvent(new CustomEvent(TUTOR_CUSTOM_EVENTS.WP_EDITOR_FOCUS, { bubbles: true })); + }); + } + }, + + setupQuickTags(this: AlpineComponent & WPEditorComponent) { + // QuickTags buttons trigger changes in text mode + const textarea = document.getElementById(this.editorId) as HTMLTextAreaElement; + + if (textarea) { + // Monitor for QuickTags button clicks + // QuickTags toolbars usually have ID "qt_{editorId}_toolbar" + const toolbarId = `qt_${this.editorId}_toolbar`; + const toolbar = document.getElementById(toolbarId); + + if (toolbar) { + toolbar.addEventListener('click', () => { + // Small delay to allow QuickTags to update the textarea + setTimeout(() => { + if (!this.isVisualMode) { + this.syncTextareaToForm(); + } + }, 50); + }); + } + } + }, + + setupFormResetListener(this: AlpineComponent & WPEditorComponent) { + // Listen for form reset events from tutorForm + window.addEventListener(TUTOR_CUSTOM_EVENTS.FORM_RESET, (event: Event) => { + const customEvent = event as CustomEvent; + const { formId, defaultValues } = customEvent.detail || {}; + + // Check if this reset event is for our form by checking if our element is inside the form + const form = this.$el.closest('form'); + if (form && form.getAttribute('x-data')?.includes(`id: '${formId}'`)) { + // Reset editor content to default value + const defaultValue = defaultValues?.[this.name] || ''; + this.setContent(defaultValue); + } + }); + }, + + syncEditorToForm(this: AlpineComponent & WPEditorComponent) { + if (this.editorInstance) { + const content = this.editorInstance.getContent(); + this.updateFormValue(content); + } + }, + + syncTextareaToForm(this: AlpineComponent & WPEditorComponent) { + const textarea = document.getElementById(this.name) as HTMLTextAreaElement; + if (textarea) { + this.updateFormValue(textarea.value); + } + }, + + updateFormValue(this: AlpineComponent & WPEditorComponent, content: string) { + // Get the hidden input that's bound to the form + const hiddenInput = this.$el.querySelector(`input[name="${this.name}"]`) as HTMLInputElement; + + if (hiddenInput) { + // Update the hidden input value + hiddenInput.value = content; + + // Trigger input event to update form state + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, + + triggerBlur(this: AlpineComponent & WPEditorComponent) { + const hiddenInput = this.$el.querySelector(`input[name="${this.name}"]`) as HTMLInputElement; + + if (hiddenInput) { + hiddenInput.dispatchEvent(new Event('blur', { bubbles: true })); + } + }, + + getContent(this: AlpineComponent & WPEditorComponent): string { + if (this.isVisualMode && this.editorInstance) { + return this.editorInstance.getContent(); + } + + const textarea = document.getElementById(this.name) as HTMLTextAreaElement; + return textarea ? textarea.value : ''; + }, + + setContent(this: AlpineComponent & WPEditorComponent, content: string) { + if (this.isVisualMode && this.editorInstance) { + this.editorInstance.setContent(content); + } + + const textarea = document.getElementById(this.name) as HTMLTextAreaElement; + if (textarea) { + textarea.value = content; + } + + this.updateFormValue(content); + }, + }; +}; + +export const wpEditorMeta: AlpineComponentMeta = { + name: 'WPEditor', + component: wpEditor, +}; diff --git a/assets/react/v3/shared/config/config.ts b/assets/core/ts/config/config.ts similarity index 79% rename from assets/react/v3/shared/config/config.ts rename to assets/core/ts/config/config.ts index f163e47ae7..e495944feb 100644 --- a/assets/react/v3/shared/config/config.ts +++ b/assets/core/ts/config/config.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ const defaultTutorConfig = { ID: 0, ajaxurl: '', @@ -32,12 +33,27 @@ const defaultTutorConfig = { course_list_page_url: '', course_post_type: '', local: '', - difficulty_levels: [], - supported_video_sources: [], - edd_products: [], - bp_groups: [], - timezones: {}, - addons_data: [], + tutor_pn_vapid_key: '', + tutor_pn_client_id: '', + tutor_pn_subscription_saved: '', + difficulty_levels: [] as any[], + supported_video_sources: [] as any[], + edd_products: [] as any[], + bp_groups: [] as any[], + timezones: {} as Record, + addons_data: [] as any[], + kids_icons_registry: [] as any[], + is_kids_mode: false, + user_preferences: { + auto_play_next: false, + contrast: '', + font_scale: 1, + learning_mood: 'modern', + motion_effects: 'auto', + theme: 'light', + vision: 'normal', + }, + is_legacy_learning_mode: false, current_user: { data: { id: '', @@ -51,13 +67,14 @@ const defaultTutorConfig = { user_status: '', display_name: '', }, - caps: {}, + caps: {} as Record, cap_key: '', - roles: [], - allcaps: {}, + roles: [] as any[], + allcaps: {} as Record, filter: null, }, settings: { + learning_mode: '', monetize_by: 'tutor', enable_course_marketplace: 'off', course_permalink_base: '', @@ -92,7 +109,8 @@ const defaultTutorConfig = { }, }; -export const tutorConfig = window._tutorobject || defaultTutorConfig; +export const tutorConfig = (window._tutorobject || defaultTutorConfig) as typeof defaultTutorConfig & + Record; window.ajaxurl = tutorConfig.ajaxurl; const config = { diff --git a/assets/core/ts/constant.ts b/assets/core/ts/constant.ts new file mode 100644 index 0000000000..aa728109ca --- /dev/null +++ b/assets/core/ts/constant.ts @@ -0,0 +1,20 @@ +export const TUTOR_CUSTOM_EVENTS = { + TAB_CHANGE: 'tutor-tab-change', + MODAL_OPEN: 'tutor-modal-open', + MODAL_UPDATE: 'tutor-modal-update', + MODAL_CLOSE: 'tutor-modal-close', + MODAL_CLOSED: 'tutor-modal-closed', + TOAST_SHOW: 'tutor-toast-show', + TOAST_CLEAR: 'tutor-toast-clear', + FORM_REGISTER: 'tutor-form-register', + FORM_UNREGISTER: 'tutor-form-unregister', + FORM_STATE_CHANGE: 'tutor-form-state-change', + FORM_RESET: 'tutor-form-reset', + WP_EDITOR_FOCUS: 'wp-editor-focus', + TUTOR_PLAYER_READY: 'tutor-player-ready', + COMMENT_REPLIED: 'tutor:comment:replied', + LESSON_PLAYER_READY: 'tutorLessonPlayerReady', + QUIZ_TIME_EXPIRED: 'tutor-quiz-time-expired', + QUIZ_ABANDON_REQUESTED: 'tutor-quiz-abandon-requested', + QUIZ_ATTEMPT_COMPLETED: 'tutor-quiz-attempt-completed', +}; diff --git a/assets/core/ts/date-formats.ts b/assets/core/ts/date-formats.ts new file mode 100644 index 0000000000..1572048cc5 --- /dev/null +++ b/assets/core/ts/date-formats.ts @@ -0,0 +1,19 @@ +/** + * Date format constants compatible with dayjs + * These formats replace date-fns formats for dayjs compatibility + */ +export enum DateFormats { + day = 'DD', + month = 'MMM', + year = 'YYYY', + yearMonthDay = 'YYYY-MM-DD', + monthDayYear = 'MMM DD, YYYY', + hoursMinutes = 'hh:mm A', + yearMonthDayHourMinuteSecond = 'YYYY-MM-DD hh:mm:ss', + yearMonthDayHourMinuteSecond24H = 'YYYY-MM-DD HH:mm:ss', + monthDayYearHoursMinutes = 'MMM DD, YYYY, hh:mm A', + localMonthDayYearHoursMinutes = 'MMM DD, YYYY hh:mm A', + activityDate = 'MMM DD, YYYY at hh:mm A', + validityDate = 'DD MMMM YYYY', + dayMonthYear = 'MMMM D, YYYY', +} diff --git a/assets/core/ts/declaration.d.ts b/assets/core/ts/declaration.d.ts new file mode 100644 index 0000000000..9ca49f5f06 --- /dev/null +++ b/assets/core/ts/declaration.d.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type Alpine as AlpineType } from 'alpinejs'; + +import { type TutorComponentRegistry } from '@Core/ts/ComponentRegistry'; +import { type ToastService } from '@Core/ts/services/toast/Toast'; +import { type TutorCore } from '@Core/ts/types'; +import { type ToastType } from '@Core/ts/types/toast'; + +interface WPMediaFrameOptions { + title?: string; + button?: { text?: string }; + multiple?: boolean | 'add'; + library?: { type?: string }; +} + +interface WPMediaState { + get: (key: string) => WPMediaSelection; +} + +interface WPMediaSelection { + reset: () => void; + add: (attachment: WPMediaAttachmentModel) => void; + toJSON: () => any[]; +} + +interface WPMediaAttachmentJSON { + id: number; + title: string; + filename: string; + url: string; + mime: string; + type: string; + subtype: string; + filesizeHumanReadable: string; + filesizeInBytes: number; +} + +interface WPMediaAttachmentModel { + fetch: () => void; +} + +interface WPMediaFrame { + on: (event: string, callback: () => void) => void; + off: (event: string, callback: () => void) => void; + open: () => void; + close: () => void; + state: () => WPMediaState; + $el: { + attr: (key: string, value: string) => void; + parent: () => { parent: () => { remove: () => void } }; + }; +} + +declare global { + interface Window { + Alpine: AlpineType; + TutorComponentRegistry: typeof TutorComponentRegistry; + TutorLessonPlayer?: Plyr; + TutorCore: TutorCore & { + toast?: ToastService; + security?: { + escapeHtml: (text: string) => string; + escapeAttr: (text: string) => string; + }; + nonce?: { + getNonceData: (sendKeyValue?: boolean) => Record | { key: string; value: string }; + }; + drawOnImage?: { + init: (options: { + image: HTMLImageElement | null; + canvas: HTMLCanvasElement; + hiddenInput?: HTMLInputElement | null; + brushSize?: number; + strokeStyle?: string; + initialMaskUrl?: string; + onMaskChange?: (value: string) => void; + interactionRoot?: HTMLElement | null; + activateOnHover?: boolean; + }) => { destroy: () => void }; + DEFAULT_BRUSH_SIZE?: number; + DEFAULT_STROKE_STYLE?: string; + }; + }; + + // Legacy functions (deprecated) + tutor_get_nonce_data: (sendKeyValue?: boolean) => Record | { key: string; value: string }; + tutor_toast: (title: string, description?: string, type?: ToastType, autoClose?: boolean) => void; + tutor_esc_html: (text: string) => string; + tutor_esc_attr: (text: string) => string; + defaultErrorMessage: string; + + // WordPress i18n and media + wp: { + i18n: { + __(text: string, domain?: string): string; + [key: string]: any; + }; + media: ((options: any) => WPMediaFrame) & { + attachment: (id: number) => WPMediaAttachmentModel; + [key: string]: any; + }; + [key: string]: any; + }; + + // WordPress editor (TinyMCE and QuickTags) + tinymce: { + get(id: string): any; + [key: string]: any; + }; + quicktags?: unknown; + + // Tutor object from PHP (extend existing type, don't redeclare) + _tutorobject?: Record & { + nonce_key?: string; + ajaxurl?: string; + tutor_url?: string; + wp_date_format?: string; + is_legacy_learning_mode?: boolean; + }; + } +} + +declare const __TUTOR_TEXT_DOMAIN__: string; diff --git a/assets/core/ts/index.ts b/assets/core/ts/index.ts new file mode 100644 index 0000000000..fdb3b7219a --- /dev/null +++ b/assets/core/ts/index.ts @@ -0,0 +1,177 @@ +import collapse from '@alpinejs/collapse'; +import focus from '@alpinejs/focus'; +import Alpine from 'alpinejs'; + +import { TutorComponentRegistry } from '@Core/ts/ComponentRegistry'; +import { accordionMeta } from '@Core/ts/components/accordion'; +import { copyToClipboardMeta } from '@Core/ts/components/copy-to-clipboard'; +import { iconMeta } from '@Core/ts/components/icon'; +import { modalMeta } from '@Core/ts/components/modal'; +import { passwordInputMeta } from '@Core/ts/components/password-input'; +import { playerMeta } from '@Core/ts/components/player'; +import { popoverMeta } from '@Core/ts/components/popover'; +import { previewTriggerMeta } from '@Core/ts/components/preview-trigger'; +import { readMoreMeta } from '@Core/ts/components/read-more'; +import { starRatingMeta } from '@Core/ts/components/star-rating'; +import { staticsMeta } from '@Core/ts/components/statics'; +import { statusSelectMeta } from '@Core/ts/components/status-select'; +import { tabsMeta } from '@Core/ts/components/tabs'; +import { toastMeta } from '@Core/ts/components/toast'; +import { tooltipMeta } from '@Core/ts/components/tooltip'; +import { wpEditorMeta } from '@Core/ts/components/wp-editor'; +import { tutorConfig } from '@Core/ts/config/config'; +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; +import { registerLegacyFunctions } from '@Core/ts/legacy'; +import { formServiceMeta } from '@Core/ts/services/Form'; +import { modalServiceMeta } from '@Core/ts/services/Modal'; +import { preferenceServiceMeta } from '@Core/ts/services/Preference'; +import { queryServiceMeta } from '@Core/ts/services/Query'; +import { toastServiceMeta } from '@Core/ts/services/toast/Toast'; +import { wpMediaServiceMeta } from '@Core/ts/services/WPMedia'; +import { wpGet, wpPost, wpPostForm } from '@Core/ts/utils/api'; +import { getRequiredComponents } from '@Core/ts/utils/component-discovery'; +import { createPriceFormatter, formatPrice } from '@Core/ts/utils/currency'; +import { decodeHtmlEntities } from '@Core/ts/utils/decode-html-entities'; +import endpoints from '@Core/ts/utils/endpoints'; +import { convertToErrorMessage } from '@Core/ts/utils/error'; +import { formatBytes } from '@Core/ts/utils/format'; +import { getNonceData } from '@Core/ts/utils/nonce'; +import { parseNumberOnly } from '@Core/ts/utils/number'; +import { escapeAttr, escapeHtml } from '@Core/ts/utils/security'; +import { makeFirstCharacterUpperCase } from '@Core/ts/utils/string'; +import { isMobileDevice, isRTL } from '@Core/ts/utils/util'; + +Alpine.plugin(focus); +Alpine.plugin(collapse); + +const initializePlugin = async () => { + TutorComponentRegistry.registerAll({ + components: [ + tabsMeta, + iconMeta, + modalMeta, + popoverMeta, + staticsMeta, + accordionMeta, + tooltipMeta, + previewTriggerMeta, + readMoreMeta, + starRatingMeta, + toastMeta, + playerMeta, + passwordInputMeta, + copyToClipboardMeta, + wpEditorMeta, + statusSelectMeta, + ], + services: [ + formServiceMeta, + modalServiceMeta, + queryServiceMeta, + toastServiceMeta, + wpMediaServiceMeta, + preferenceServiceMeta, + ], + }); + + TutorComponentRegistry.registerLazy({ + calendar: () => + import( + /* webpackChunkName: "tutor-calendar" */ + '@Core/ts/components/calendar' + ).then(({ calendarMeta }) => calendarMeta), + + form: () => + import( + /* webpackChunkName: "tutor-form" */ + '@Core/ts/components/form' + ).then(({ formMeta }) => formMeta), + + fileUploader: () => + import( + /* webpackChunkName: "tutor-file-uploader" */ + '@Core/ts/components/file-uploader' + ).then(({ fileUploaderMeta }) => fileUploaderMeta), + + select: () => + import( + /* webpackChunkName: "tutor-select" */ + '@Core/ts/components/select' + ).then(({ selectMeta }) => selectMeta), + + timeInput: () => + import( + /* webpackChunkName: "tutor-time-input" */ + '@Core/ts/components/time-input' + ).then(({ timeInputMeta }) => timeInputMeta), + }); + + await TutorComponentRegistry.loadComponents(getRequiredComponents()); + + TutorComponentRegistry.initWithAlpine(Alpine); + + window.TutorComponentRegistry = TutorComponentRegistry; + window.Alpine = Alpine; + + // Expose TutorCore with services and utilities + // Use Object.assign to extend existing TutorCore instead of overwriting + window.TutorCore = Object.assign(window.TutorCore || {}, { + toast: toastServiceMeta.instance, + security: { + escapeHtml, + escapeAttr, + }, + nonce: { + getNonceData, + }, + api: { + wpPost, + wpPostForm, + wpGet, + }, + error: { + convertToErrorMessage, + }, + string: { + decodeHtmlEntities, + makeFirstCharacterUpperCase, + }, + device: { + isMobileDevice, + isRTL, + }, + number: { + parseNumberOnly, + }, + format: { + formatBytes, + }, + currency: { + createPriceFormatter, + formatPrice, + }, + constants: { + TUTOR_CUSTOM_EVENTS, + }, + config: { + tutorConfig: tutorConfig, + }, + endpoints: endpoints, + }); + + // Register legacy functions for backward compatibility + // This should be called AFTER TutorCore is set up + registerLegacyFunctions(); + + Alpine.start(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initializePlugin(); + }); +} else { + initializePlugin(); +} + +export { Alpine, TutorComponentRegistry }; diff --git a/assets/core/ts/legacy/index.ts b/assets/core/ts/legacy/index.ts new file mode 100644 index 0000000000..6b08b6ae6f --- /dev/null +++ b/assets/core/ts/legacy/index.ts @@ -0,0 +1,40 @@ +/** + * Legacy compatibility layer for old Tutor LMS window functions + * + * This module provides backward compatibility wrappers that translate + * old API calls to the new core system, ensuring zero breaking changes. + * + * @since 4.0.0 + */ + +import { tutor_get_nonce_data } from './nonce'; +import { tutor_esc_attr, tutor_esc_html } from './security'; +import { tutor_toast } from './toast'; + +/** + * Register legacy functions to the window object for backward compatibility + * + * This function should be called after the core system is initialized + * to ensure all services are available. + * + * @since 4.0.0 + */ +export function registerLegacyFunctions(): void { + // Nonce handling + window.tutor_get_nonce_data = tutor_get_nonce_data; + + // Toast notifications + window.tutor_toast = tutor_toast; + + // Security utilities + window.tutor_esc_html = tutor_esc_html; + window.tutor_esc_attr = tutor_esc_attr; + + // Set default error message from WordPress i18n + if (typeof wp !== 'undefined' && wp.i18n) { + window.defaultErrorMessage = wp.i18n.__('Something went wrong', 'tutor'); + } +} + +// Export all legacy functions for direct import if needed +export { tutor_esc_attr, tutor_esc_html, tutor_get_nonce_data, tutor_toast }; diff --git a/assets/core/ts/legacy/nonce.ts b/assets/core/ts/legacy/nonce.ts new file mode 100644 index 0000000000..ae0028a1b7 --- /dev/null +++ b/assets/core/ts/legacy/nonce.ts @@ -0,0 +1,21 @@ +/** + * Legacy nonce function wrapper for backward compatibility + * + * @since 4.0.0 + */ + +import { getNonceData } from '@Core/ts/utils/nonce'; + +/** + * Legacy tutor_get_nonce_data wrapper + * + * @deprecated Use getNonceData from @Core/ts/utils/nonce instead + * + * @param sendKeyValue - If true, returns {key, value}, otherwise returns {[key]: value} + * @returns Nonce data object + * + * @since 4.0.0 + */ +export function tutor_get_nonce_data(sendKeyValue?: boolean) { + return getNonceData(sendKeyValue); +} diff --git a/assets/core/ts/legacy/security.ts b/assets/core/ts/legacy/security.ts new file mode 100644 index 0000000000..5e3ab4f27d --- /dev/null +++ b/assets/core/ts/legacy/security.ts @@ -0,0 +1,35 @@ +/** + * Legacy security function wrappers for backward compatibility + * + * @since 4.0.0 + */ + +import { escapeAttr, escapeHtml } from '@Core/ts/utils/security'; + +/** + * Legacy tutor_esc_html wrapper + * + * @deprecated Use escapeHtml from @Core/ts/utils/security instead + * + * @param unsafeText - HTML string to escape + * @returns Escaped HTML string + * + * @since 4.0.0 + */ +export function tutor_esc_html(unsafeText: string): string { + return escapeHtml(unsafeText); +} + +/** + * Legacy tutor_esc_attr wrapper + * + * @deprecated Use escapeAttr from @Core/ts/utils/security instead + * + * @param str - String to escape + * @returns Escaped attribute string + * + * @since 4.0.0 + */ +export function tutor_esc_attr(str: string): string { + return escapeAttr(str); +} diff --git a/assets/core/ts/legacy/toast.ts b/assets/core/ts/legacy/toast.ts new file mode 100644 index 0000000000..c4048fa332 --- /dev/null +++ b/assets/core/ts/legacy/toast.ts @@ -0,0 +1,51 @@ +/** + * Legacy tutor_toast wrapper for backward compatibility + * + * @since 4.0.0 + */ + +import type { ToastType } from '@Core/ts/types/toast'; + +/** + * Legacy tutor_toast function that wraps the new core toast service + * + * This function maintains backward compatibility with the old API signature: + * tutor_toast(title, description, type, autoClose) + * + * @deprecated Use window.TutorCore.toast methods instead + * + * @param title - Toast title (used as message if description is empty) + * @param description - Toast description (becomes the main message) + * @param type - Toast type: 'success' | 'error' | 'warning' | 'info' + * @param autoClose - Whether to auto-dismiss (default: true) + * + * @example + * // Legacy usage (still works) + * tutor_toast('Success', 'Item saved successfully', 'success'); + * + * // Translates to new API + * TutorCore.toast.show('Item saved successfully', { type: 'success', title: 'Success' }); + * + * @since 4.0.0 + */ +export function tutor_toast(title: string, description?: string, type: ToastType = 'info', autoClose = true): void { + // Determine message and title for new API + // If description exists, use it as message and title as custom title + // If no description, use title as message with default title + const message = description || title; + const toastTitle = description ? title : undefined; + const duration = autoClose ? 5000 : 0; + + // Use the new toast service + if (window.TutorCore?.toast) { + window.TutorCore.toast.show(message, { + type, + ...(toastTitle && { title: toastTitle }), + duration, + }); + } else { + // Fallback to console if core is not loaded + // eslint-disable-next-line no-console + console.warn('[Tutor Toast] Core toast service not available:', message); + } +} diff --git a/assets/core/ts/plyr.d.ts b/assets/core/ts/plyr.d.ts new file mode 100644 index 0000000000..eb27a3ac7f --- /dev/null +++ b/assets/core/ts/plyr.d.ts @@ -0,0 +1,728 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Type definitions for plyr 3.5 +// Project: https://plyr.io +// Definitions by: ondratra +// TypeScript Version: 3.0 + +declare class Plyr { + /** + * Setup a new instance + */ + static setup(targets: NodeList | HTMLElement | HTMLElement[] | string, options?: Plyr.Options): Plyr[]; + + /** + * Check for support + * @param mediaType + * @param provider + * @param playsInline Whether the player has the playsinline attribute (only applicable to iOS 10+) + */ + static supported(mediaType?: Plyr.MediaType, provider?: Plyr.Provider, playsInline?: boolean): Plyr.Support; + + constructor(targets: NodeList | HTMLElement | HTMLElement[] | string, options?: Plyr.Options); + + /** + * Indicates if the current player is HTML5. + */ + readonly isHTML5: boolean; + + /** + * Indicates if the current player is an embedded player. + */ + readonly isEmbed: boolean; + + /** + * Indicates if the current player is playing. + */ + readonly playing: boolean; + + /** + * Indicates if the current player is paused. + */ + readonly paused: boolean; + + /** + * Indicates if the current player is stopped. + */ + readonly stopped: boolean; + + /** + * Indicates if the current player has finished playback. + */ + readonly ended: boolean; + + /** + * Returns a float between 0 and 1 indicating how much of the media is buffered + */ + readonly buffered: number; + + /** + * Gets or sets the currentTime for the player. The setter accepts a float in seconds. + */ + currentTime: number; + + /** + * Indicates if the current player is seeking. + */ + readonly seeking: boolean; + + /** + * Returns the duration for the current media. + */ + readonly duration: number; + + /** + * Gets or sets the volume for the player. The setter accepts a float between 0 and 1. + */ + volume: number; + + /** + * Gets or sets the muted state of the player. The setter accepts a boolean. + */ + muted: boolean; + + /** + * Indicates if the current media has an audio track. + */ + readonly hasAudio: boolean; + + /** + * Gets or sets the speed for the player. The setter accepts a value in the options specified in your config. Generally the minimum should be 0.5. + */ + speed: number; + + /** + * Gets or sets the quality for the player. The setter accepts a value from the options specified in your config. + */ + quality: number; + + /** + * Gets or sets the current loop state of the player. + */ + loop: boolean; + + /** + * Gets or sets the current source for the player. + */ + source: Plyr.SourceInfo; + + /** + * Gets or sets the current poster image URL for the player. + */ + poster: string; + + /** + * Gets or sets the autoplay state of the player. + */ + autoplay: boolean; + + /** + * Gets or sets the caption track by index. 1 means the track is missing or captions is not active + */ + currentTrack: number; + + /** + * Gets or sets the preferred captions language for the player. The setter accepts an ISO twoletter language code. Support for the languages is dependent on the captions you include. + * If your captions don't have any language data, or if you have multiple tracks with the same language, you may want to use currentTrack instead. + */ + language: string; + + /** + * Gets or sets the picture-in-picture state of the player. This currently only supported on Safari 10+ on MacOS Sierra+ and iOS 10+. + */ + pip: boolean; + + /** + * Gets or sets the aspect ratio for embedded players. + */ + ratio?: string; + + /** + * Access Elements cache + */ + elements: Plyr.Elements; + + /** + * Returns the current video Provider + */ + readonly provider: Plyr.Provider; + + /** + * Returns the native API for Vimeo or Youtube players + */ + readonly embed?: any; + + readonly fullscreen: Plyr.FullscreenControl; + + /** + * Start playback. + * For HTML5 players, play() will return a Promise in some browsers - WebKit and Mozilla according to MDN at time of writing. + */ + play(): Promise | void; + + /** + * Pause playback. + */ + pause(): void; + + /** + * Toggle playback, if no parameters are passed, it will toggle based on current status. + */ + togglePlay(toggle?: boolean): boolean; + + /** + * Stop playback and reset to start. + */ + stop(): void; + + /** + * Restart playback. + */ + restart(): void; + + /** + * Rewind playback by the specified seek time. If no parameter is passed, the default seek time will be used. + */ + rewind(seekTime?: number): void; + + /** + * Fast forward by the specified seek time. If no parameter is passed, the default seek time will be used. + */ + forward(seekTime?: number): void; + + /** + * Increase volume by the specified step. If no parameter is passed, the default step will be used. + */ + increaseVolume(step?: number): void; + + /** + * Increase volume by the specified step. If no parameter is passed, the default step will be used. + */ + decreaseVolume(step?: number): void; + + /** + * Toggle captions display. If no parameter is passed, it will toggle based on current status. + */ + toggleCaptions(toggle?: boolean): void; + + /** + * Trigger the airplay dialog on supported devices. + */ + airplay(): void; + + /** + * Sets the preview thumbnails for the current source. + */ + setPreviewThumbnails(source: Plyr.PreviewThumbnailsOptions): void; + + /** + * Toggle the controls (video only). Takes optional truthy value to force it on/off. + */ + toggleControls(toggle: boolean): void; + + /** + * Add an event listener for the specified event. + */ + on(event: K, callback: (this: this, event: Plyr.PlyrEventMap[K]) => void): void; + + /** + * Add an event listener for the specified event once. + */ + once(event: K, callback: (this: this, event: Plyr.PlyrEventMap[K]) => void): void; + + /** + * Remove an event listener for the specified event. + */ + off(event: K, callback: (this: this, event: Plyr.PlyrEventMap[K]) => void): void; + + /** + * Check support for a mime type. + */ + supports(type: string): boolean; + + /** + * Destroy lib instance + * @param {Function} callback - Callback for when destroy is complete + * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc) + */ + destroy(callback?: (...args: any[]) => void, soft?: boolean): void; +} + +declare namespace Plyr { + type MediaType = 'audio' | 'video'; + type Provider = 'html5' | 'youtube' | 'vimeo'; + type StandardEventMap = { + progress: PlyrEvent; + playing: PlyrEvent; + play: PlyrEvent; + pause: PlyrEvent; + timeupdate: PlyrEvent; + volumechange: PlyrEvent; + seeking: PlyrEvent; + seeked: PlyrEvent; + ratechange: PlyrEvent; + ended: PlyrEvent; + enterfullscreen: PlyrEvent; + exitfullscreen: PlyrEvent; + captionsenabled: PlyrEvent; + captionsdisabled: PlyrEvent; + languagechange: PlyrEvent; + controlshidden: PlyrEvent; + controlsshown: PlyrEvent; + ready: PlyrEvent; + }; + // For retrocompatibility, we keep StandardEvent + type StandardEvent = keyof Plyr.StandardEventMap; + type Html5EventMap = { + loadstart: PlyrEvent; + loadeddata: PlyrEvent; + loadedmetadata: PlyrEvent; + canplay: PlyrEvent; + canplaythrough: PlyrEvent; + stalled: PlyrEvent; + waiting: PlyrEvent; + emptied: PlyrEvent; + cuechange: PlyrEvent; + error: PlyrEvent; + }; + // For retrocompatibility, we keep Html5Event + type Html5Event = keyof Plyr.Html5EventMap; + type YoutubeEventMap = { + statechange: PlyrStateChangeEvent; + qualitychange: PlyrEvent; + qualityrequested: PlyrEvent; + }; + // For retrocompatibility, we keep YoutubeEvent + type YoutubeEvent = keyof Plyr.YoutubeEventMap; + + type PlyrEventMap = StandardEventMap & Html5EventMap & YoutubeEventMap; + + interface FullscreenControl { + /** + * Indicates if the current player is in fullscreen mode. + */ + readonly active: boolean; + + /** + * Indicates if the current player has fullscreen enabled. + */ + readonly enabled: boolean; + + /** + * Enter fullscreen. If fullscreen is not supported, a fallback ""full window/viewport"" is used instead. + */ + enter(): void; + + /** + * Exit fullscreen. + */ + exit(): void; + + /** + * Toggle fullscreen. + */ + toggle(): void; + } + + interface Options { + /** + * Completely disable Plyr. This would allow you to do a User Agent check or similar to programmatically enable or disable Plyr for a certain UA. Example below. + */ + enabled?: boolean; + + /** + * Display debugging information in the console + */ + debug?: boolean; + + /** + * If a function is passed, it is assumed your method will return either an element or HTML string for the controls. Three arguments will be passed to your function; + * id (the unique id for the player), seektime (the seektime step in seconds), and title (the media title). See CONTROLS.md for more info on how the html needs to be structured. + * Defaults to ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'] + */ + controls?: string | string[] | ((id: string, seektime: number, title: string) => unknown) | Element; + + /** + * If you're using the default controls are used then you can specify which settings to show in the menu + * Defaults to ['captions', 'quality', 'speed', 'loop'] + */ + settings?: string[]; + + /** + * Used for internationalization (i18n) of the text within the UI. + */ + i18n?: any; + + /** + * Load the SVG sprite specified as the iconUrl option (if a URL). If false, it is assumed you are handling sprite loading yourself. + */ + loadSprite?: boolean; + + /** + * Specify a URL or path to the SVG sprite. See the SVG section for more info. + */ + iconUrl?: string; + + /** + * Specify the id prefix for the icons used in the default controls (e.g. plyr-play would be plyr). + * This is to prevent clashes if you're using your own SVG sprite but with the default controls. + * Most people can ignore this option. + */ + iconPrefix?: string; + + /** + * Specify a URL or path to a blank video file used to properly cancel network requests. + */ + blankVideo?: string; + + /** + * Autoplay the media on load. This is generally advised against on UX grounds. It is also disabled by default in some browsers. + * If the autoplay attribute is present on a
- } - placeholder={__('Select Prerequisite', 'tutor')} - options={ - topics.reduce((topics, topic) => { - topics.push({ - ...topic, - contents: topic.contents.filter((content) => String(content.ID) !== String(quizId)), - }); - - return topics; - }, [] as CourseTopic[]) || [] - } - isSearchable - helpText={__('Select items that should be complete before this item', 'tutor')} - /> - )} - /> - - - - - - -
- ( - - )} - /> - -
- - ( - - )} - /> - - - ( - - )} - /> -
- - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - -
-
- - - - ); -}; - -export default QuizSettings; - -const styles = { - settings: css` - ${styleUtils.display.flex('column')} - gap: ${spacing[24]}; - `, - formWrapper: css` - ${styleUtils.display.flex('column')} - gap: ${spacing[20]}; - `, - timeWrapper: css` - ${styleUtils.display.flex()} - align-items: flex-start; - gap: ${spacing[8]}; - `, - questionLayoutAndOrder: css` - ${styleUtils.display.flex()} - gap: ${spacing[20]}; - - ${Breakpoint.smallMobile} { - flex-direction: column; - } - `, - contentDripLabel: css` - display: flex; - align-items: center; - - svg { - margin-right: ${spacing[4]}; - color: ${colorTokens.icon.success}; - } - `, -}; diff --git a/assets/scss/admin-dashboard/_tutor-admin.scss b/assets/scss/admin-dashboard/_tutor-admin.scss deleted file mode 100644 index b4fb10526c..0000000000 --- a/assets/scss/admin-dashboard/_tutor-admin.scss +++ /dev/null @@ -1,1922 +0,0 @@ -/** -@package: Tutor LMS -@author: Themeum - */ -.tutor-admin { - &-wrap { - margin-left: -20px; - .rtl & { - margin-left: 0px; - margin-right: -20px; - } - - @include breakpoint-max(782) { - margin-left: -10px; - .rtl & { - margin-left: 0px; - margin-right: -10px; - } - } - - // reset - *, - ::after, - ::before { - box-sizing: border-box; - } - - a { - &, - &:hover, - &:active, - &:focus { - text-decoration: none; - } - } - - ul { - list-style: none; - padding: 0; - margin: 0; - - li { - margin: 0; - } - } - } - - &-body { - padding-left: 20px; - padding-right: 20px; - } - - &-container { - max-width: 1160px; - margin-left: auto; - margin-right: auto; - padding-left: 20px; - padding-right: 20px; - - &-lg { - max-width: 1380px; - } - - &-sm { - max-width: 760px; - min-width: 480px; - } - } - - &-header { - box-sizing: border-box; - background: #ffffff; - border-bottom: 1px solid #cdcfd5; - padding: 12px 24px; - - &.is-sticky { - position: sticky; - top: 32px; - z-index: 1024; - - // admin bar - @media screen and (max-width: 782px) { - top: 42px; - } - - @media screen and (max-width: 600px) { - top: 0; - } - } - } -} - -// overrides -.tutor-admin-wrap, -.tutor-admin-post-meta, -.tutor-admin-design-init { - .tutor-form { - &-control { - outline: none !important; - padding: 8px 16px !important; - font-size: 16px !important; - font-weight: 400 !important; - line-height: 1.4 !important; - height: auto !important; - min-height: initial !important; - border-radius: 6px !important; - border: 1px solid var(--tutor-border-color) !important; - - &:focus { - border-color: var(--tutor-color-primary) !important; - box-shadow: unset !important; - outline: none !important; - } - } - - // icon - &-icon { - &:not(.tutor-form-icon-reverse) ~ .tutor-form-control { - padding-left: 40px !important; - - .rtl & { - padding-left: 16px !important; - padding-right: 40px !important; - } - } - - &-reverse ~ .tutor-form-control { - padding-right: 40px !important; - - .rtl & { - padding-right: 16px !important; - padding-left: 40px !important; - } - } - } - } - - // select - select.tutor-form-control, - .tutor-form-select { - padding-right: 36px !important; - margin: 0px; - } -} - -table.tutor-table.tutor-table-with-checkbox { - .td-checkbox input[type='checkbox'] { - margin: 0; - } -} - -// Hide empty text link from dashboard menu. -#adminmenu { - & li a[href=tutor-setup] { - display: none; - } -} - -#toplevel_page_tutor { - a:has( > .tutor-admin-menu-separator){ - pointer-events: none; - margin: 8px 12px; - padding: 0!important; - height: 1px; - overflow: hidden; - background: #4A5257; - color: transparent; - } -} - -body.tutor-backend { - background-color: #f5f5f5; -} - -/*---------------------------------- - Component containers -----------------------------------*/ - -body.tutor-backend-tutor_settings #wpbody-content { - min-height: 100vh; -} - -.ui-datepicker-buttonpane.ui-widget-content { - padding-top: 6px; - background: #ffffff; - color: #222222; - display: flex; - gap: 10px; - button[class^="ui-datepicker-"] { - padding: 2px 6px; - border-radius: 3px; - display: flex; - } -} - -.ui-widget-content a { - color: #222222; -} - -.ui-widget-header { - color: #222222; - font-weight: bold; - a { - color: #222222; - } -} - -/* Interaction states - ----------------------------------*/ - -.ui-state-default, -.ui-widget-content .ui-state-default, -.ui-widget-header .ui-state-default { - border: 1px solid #d3d3d3; - background: #e6e6e6; - font-weight: normal; - color: #555555; -} - -/* Interaction Cues - ----------------------------------*/ - -.ui-state-highlight, -.ui-widget-content .ui-state-highlight, -.ui-widget-header .ui-state-highlight { - border: 1px solid #fcefa1; - background: #fbf9ee; - color: #363636; -} - -.tutor-option-nav-tabs li { - position: relative; - display: inline-block; - margin-right: -1px; - &:last-child:after { - content: ''; - } - a { - display: block; - font-weight: bold; - text-decoration: none; - background: #fff; - padding: 5px 10px; - border: 1px solid #dddddd; - &:focus { - box-shadow: none; - } - } - .current a { - color: #333333; - } -} - -/** -* Option Field -*/ - -.tutor-hide-option{ - display: none!important; -} - -.tutor-option-no-bottom-border{ - border-bottom: 0!important; - padding-bottom: 0!important; -} - -.tutor-option-field-row { - border-bottom: 1px solid #e4e4e4; - padding: 20px 0; - font-size: 14px; - line-height: 1.3; - - &.tutor-d-flex { - display: flex; - } - - &.tutor-d-block { - display: block; - } -} - -.tutor-option-field-row:last-child { - border-bottom: none; -} - -.tutor-option-field-row input[type='text'], -.tutor-option-field-row input[type='email'], -.tutor-option-field-row input[type='number'], -.tutor-option-field-row input[type='password'], -.tutor-option-field-row textarea { - &:last-child { - margin-right: 0; - } - background-color: #fff; - border: 1px solid #ddd; - border-radius: 3px; - box-shadow: none; - color: #333; - display: inline-block; - vertical-align: middle; - padding: 7px 12px; - margin: 0 10px 0 0; - width: 400px; - min-height: 35px; -} - -.tutor_lesson_modal_form .tutor-option-field-row input[type='text'], -.tutor_lesson_modal_form .tutor-option-field-row input[type='email'], -.tutor_lesson_modal_form .tutor-option-field-row input[type='number'], -.tutor_lesson_modal_form .tutor-option-field-row input[type='password'], -.tutor_lesson_modal_form .tutor-option-field-row textarea { - width: 100%; - display: block; -} - -.tutor-option-field { - display: block; - margin: 0 0 0 200px; - max-width: 800px; -} - -.rtl .tutor-option-field { - margin: 0 200px 0 0; -} - -.tutor_lesson_modal_form .tutor-option-field { - display: block; - margin: 0; - max-width: none; -} - -.tutor-option-field-label { - display: block; - float: left; - width: 200px; -} - -.rtl .tutor-option-field-label { - float: right; -} - -.tutor_lesson_modal_form .tutor-option-field-label { - display: block; - float: none; - width: 100%; - margin-bottom: 15px; -} - -.tutor-option-field-label label { - display: block; - font-weight: 600; -} - -.tutor-option-field p.desc { - font-style: italic; - color: #666; - font-size: 12px; - line-height: 1.5; -} - -.tutor-option-field-row h2 { - color: #444; - font-size: 18px; - font-weight: 700; - margin: 0; -} - -.tutor-option-field-row .option-media-wrap { - margin-bottom: 10px; -} - -.tutor-option-field-row .option-media-wrap img { - max-height: 100px; - width: auto; - padding: 5px; - border: 1px solid #cccccc; -} - -/** - Group Field Option - */ - -.tutor-option-group-field { - display: inline-block; - vertical-align: top; -} - -.tutor-option-group-field input[type='text'], -.tutor-option-group-field input[type='email'], -.tutor-option-group-field input[type='number'], -.tutor-option-group-field input[type='password'], -.tutor-option-group-field textarea, -.tutor-option-group-field select { - width: 100px; - margin-right: 5px; -} - -.option-type-radio-wrap { - margin-top: 0; -} - -/** - * Course adding page - * Course Builder - */ - -p.course-empty-content { - padding-left: 20px; -} - -.ui-sortable-placeholder { - visibility: visible; - background-color: #dddd; -} - -/** - Instructor - */ - -.tutor-required-fields { - color: #f13a3a; -} - -/** - Meta Box Heading - */ - -.tutor-status-context { - padding: 5px 10px; - margin: 5px 0; - display: inline-block; -} - -.tutor-status-pending-context, -.attempt_started { - background-color: #eeeeee; -} - -.tutor-status-approved-context, -.tutor-button.button-success, -.tutor-status-completed { - background-color: var(--tutor-color-success); - color: #ffffff; - border-radius: 2px; -} - -.tutor-status-blocked-context, -.attempt_timeout, -.tutor-button.button-danger { - background-color: #ff0000; - color: #ffffff; - border-radius: 2px; -} - -.tutor-status-approved-context, -.tutor-status-blocked-context { - display: inline-block; -} - -table.tutor_status_table td.help { - width: 1em; -} - -table.tutor_status_table td:first-child { - width: 25%; -} - -table.tutor_status_table h2 { - font-size: 16px; - margin: 0; -} - -table.tutor_status_table td mark.yes, -table.tutor_status_table th mark.yes { - color: var(--tutor-color-success); - background-color: transparent; -} - -.tutor-text-avatar { - border-radius: 50%; - width: 40px; - min-width: 40px; - height: 40px; - text-align: center; - display: block; - line-height: 40px; - color: #ffffff; - font-size: 14px; -} - -.tutor_original_question { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - margin-bottom: 5px; - padding: 20px; -} - -.tutor_original_question .question-left { - -webkit-box-flex: 0; - -ms-flex: 0 0 60px; - flex: 0 0 60px; -} - -.tutor_original_question .question-left img { - max-width: 60px; - height: auto; - margin-right: 10px; - border: 1px solid #eeee88; - padding: 3px; -} - -.question-right { - width: 100%; -} - -.tutor_admin_answers_list_wrap .tutor_original_question { - margin-left: 50px; -} - -// @todo: will be removed -.tutor-announcement { - border: 1px solid #eee; - padding: 10px; - margin-bottom: 10px; -} - -.tutor-label-success { - background-color: var(--tutor-color-success); - color: #ffffff; - padding: 3px 7px; -} - -.tutor-addons .addon-regular-price { - color: #cccccc; - padding: 3px; -} - -.tutor-addons .addon-current-price { - color: var(--tutor-color-success); - font-size: 18px; - padding: 3px; -} - -.tutor-addons-last-checked-time { - color: #6f5757 !important; -} - -.tutor-addons .wp-filter { - margin: 10px 0 0; -} - -a.addon-buynow-link { - background: var(--tutor-color-primary); - color: #fff; - padding: 5px 10px; - display: inline-block; -} - -/* RTL Style for name and desc */ - -.tutor-lms-pro_page_tutor-addons.rtl .plugin-card .desc, -.tutor-lms-pro_page_tutor-addons.rtl .plugin-card .name { - margin-right: 148px !important; -} - -.tutor-lms-pro_page_tutor-addons.rtl .plugin-card .desc { - margin-left: 0 !important; -} - -.tutor-lms-pro_page_tutor-addons.rtl .plugin-card .name { - margin-left: 53px !important; -} - -.required-plugin-cards { - background: #fff8e5; - padding: 12px 20px; -} - -.required-plugin-cards p { - margin: 0; -} - -/** - Quiz-question - */ - -.quiz-question-form-wrap { - margin-top: 20px; - margin-bottom: 20px; -} - -.quiz-question-flex-wrap, -.tutor-flex-row { - display: flex; - flex-direction: row; -} - -.tutor-flex-col { - margin: 0 20px; -} - -.tutor-flex-col:first-child { - margin-left: 0; -} - -.tutor-flex-col:last-child { - margin-right: 0; -} - -.tutor-add-question-wrap { - margin: 20px 0; - background-color: #f4f4f4; - padding: 10px; -} - -.question-actions-wrap { - padding-right: 0 !important; -} - -.question-actions-wrap a { - display: inline-block; -} - -.tutor-loading-icon-wrap.button { - vertical-align: unset; - border: none; - background-color: transparent; - -webkit-box-shadow: none; - box-shadow: none; -} - -.tutor-info-msg, -.tutor-success-msg, -.tutor-warning-msg, -.tutor-error-msg { - margin: 10px 0; - padding: 10px; - border-radius: 3px 3px 3px 3px; -} - -.tutor-info-msg { - color: var(--tutor-color-primary); - background-color: #bef; - border: 1px solid var(--tutor-color-primary); -} - -.tutor-success-msg { - color: var(--tutor-color-success); - background-color: #dff2bf; - border: 1px solid var(--tutor-color-success); -} - -.tutor-warning-msg { - color: #9f6000; - background-color: #feefb3; - border: 1px solid #9f6000; -} - -.tutor-error-msg { - color: #d8000c; - background-color: #fbdcdc; - border: 1px solid #d8000c; -} - -.quiz-modal-btn-cancel, -.quiz-modal-btn-back { - color: #4b5981; - border: 1px solid #d4dadb; -} - -/*end notice*/ - -/* .tutor-quiz-builder-group */ - -.tutor-quiz-builder-group { - margin-bottom: 25px; -} - -.tutor-quiz-builder-group > p.warning { - color: red; - font-size: 12px; -} - -.tutor-quiz-builder-group > p.help { - color: #a4a4a4; - font-size: 12px; - margin-top: 7px; -} - -.tutor-quiz-builder-group h4 { - font-size: 14px; - color: #393c40; - font-weight: 600; - margin: 0 0 15px; -} - -.tutor-quiz-builder-row { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - margin-left: -10px; - margin-right: -10px; -} - -.tutor-quiz-builder-col { - padding-left: 10px; - padding-right: 10px; - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; -} - -.tutor-quiz-builder-col.auto-width { - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; -} - -.tutor-quiz-builder-group textarea, -.tutor-quiz-builder-group input[type='text'], -.tutor-quiz-builder-group input[type='email'], -.tutor-quiz-builder-group input[type='number'], -.tutor-quiz-builder-group input[type='password'] { - line-height: 40px; - padding: 5px 0; - text-indent: 15px; - background: #fff; - display: inline-block; - border: 1px solid #dedede; - border-radius: 3px; - -webkit-box-shadow: none; - box-shadow: none; - height: 40px; - margin: 0; - width: 100%; - color: #393c40; - font-size: 14px; -} - -.tutor-quiz-builder-group textarea:focus, -.tutor-quiz-builder-group input[type='text']:focus, -.tutor-quiz-builder-group input[type='email']:focus, -.tutor-quiz-builder-group input[type='number']:focus, -.tutor-quiz-builder-group input[type='password']:focus { - border-color: var(--tutor-color-primary); -} - -.tutor-quiz-builder-group textarea { - height: 80px; - resize: none; - text-indent: 0; - padding: 11px 15px; - line-height: 22px; -} - -.tutor-quiz-builder-group textarea[name='quiz_description'] { - height: 150px; -} - -.tutor-quiz-builder-group select { - border: 1px solid #ccc; - -webkit-box-shadow: none; - box-shadow: none; - height: 42px !important; - line-height: 1; - padding: 0 24px 0 12px !important; - margin: 0; -} - -.question-type-pro { - color: #fff; - font-size: 9px; - right: 11px; - position: absolute; - top: 50%; - -webkit-transform: translateY(-50%); - transform: translateY(-50%); -} - -.quiz-builder-question { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - background: #fff; - padding: 10px; - border: 1px solid #e2e2e2; - border-radius: 3px; - max-width: calc(100% - 52px); -} - -.quiz-builder-question .question-sorting { - margin-right: 10px; - line-height: 22px; -} - -.quiz-builder-question .question-sorting i { - display: block; - line-height: 24px; -} - -.quiz-builder-question .question-edit-icon { - line-height: 22px; -} - -.quiz-builder-question .question-title { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - line-height: 22px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - margin-right: 10px; -} - -.quiz-builder-question .question-icon { - -webkit-box-flex: 0; - -ms-flex: 0 0 155px; - flex: 0 0 155px; -} - -.quiz-builder-qustion-trash a { - display: block; - padding: 0 0 0 10px; - font-size: 20px; - color: rgba(57, 60, 64, 0.4); - line-height: 44px; -} - -.tutor-quiz-answer-wrap { - display: -webkit-box; - display: -ms-flexbox; - display: flex; -} - -.tutor-quiz-answer-trash-wrap a.answer-trash-btn { - padding: 0 10px; - display: inline-block; - line-height: 44px; -} - -span.tutor-quiz-answer-title { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.tutor-quiz-answer-media .option-media-preview { - margin-bottom: 20px; -} - -.tutor-quiz-answer-media .option-media-preview img { - max-width: 80px; - height: auto; -} - -.tutor-question-answer-image { - margin-right: 10px; -} - -.tutor-question-answer-image img { - max-height: 25px; - width: auto; -} - -/** - #End Quiz Modal - */ - -.tutor-available-quizzes .added-quiz-item { - background-color: #f5f5f5; - padding: 10px; - margin-bottom: 2px; - display: -webkit-box; - display: -ms-flexbox; - display: flex; -} - -.tutor-available-quizzes .added-quiz-item .quiz-name { - -ms-flex-preferred-size: 0; - flex-basis: 0; - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; -} - -.tutor-quiz-delete-btn { - color: #ff0000; -} - -p.quiz-search-suggest-text { - margin-top: 30px; - font-style: italic; - font-size: 12px; -} - -span.result-pass { - background-color: var(--tutor-color-success); - color: #fff; - padding: 3px 5px; - border-radius: 2px; -} - -span.result-fail { - color: #ff0000; -} - -span.result-review-required { - background: #f5b30d; - color: #fff; - padding: 3px 5px; - border-radius: 2px; -} - -.tutor-emails-lists-wrap { - background-color: #ffffff; - padding: 20px; -} - -.tutor-emails-lists-wrap .wp-list-table td { - padding: 10px 20px; -} - -.image-matching-item { - -webkit-box-flex: 0; - -ms-flex: 0 0 50px; - flex: 0 0 50px; - margin-right: 10px; -} - -.image-matching-item p { - margin-bottom: 5px; - margin-top: 0; - color: #878a8f; -} - -.image-matching-item img { - max-width: 80px; -} - -span.filled_dash_unser { - font-weight: bold; - text-decoration: underline; - margin: 0 5px; -} - -/** - Uninstall - */ - -.wrap.tutor-uninstall-wrap { - background: #fff; - padding: 20px; -} - -.tutor-uninstall-btn-group { - margin: 50px 0; -} - -.lesson-modal-field.tutor-lesson-modal-title-wrap { - width: 95%; -} - -.tutor-lesson-modal-title-wrap input { - width: 100%; -} - -.tutor-lesson-modal-wrap .modal-footer { - padding: 10px 20px; - background-color: #fff; - width: 100%; - position: sticky; - bottom: 0; - position: -webkit-sticky; -} - -.tutor-option-field .tutor-lesson-edit-feature-img { - width: 100px; - position: relative; -} - -.tutor-option-field .tutor-lesson-edit-feature-img img { - width: 100%; - height: auto; -} - -a:has(> span.tutor-get-pro-text ) { - background-color: orange; - font-weight: 600; - color: #000 !important; - - &:hover{ - background-color: orange!important; - color: #000 !important; - } -} - -.tutor-text-orange { - color: orange; -} - -.updating-icon:before { - font-family: 'tutor'; - margin-right: 5px; - content: '\e91d'; - -webkit-animation: spin 1s steps(8) infinite; - animation: spin 1s steps(8) infinite; - display: inline-block; -} - -.tutor-notice-warning { - background-color: #fcf8e3; - border-color: #faebcc; - padding: 20px; - margin-bottom: 10px; -} - -/** - Tutor Notice - */ - -.tnotice { - text-align: left; - padding: 10px 0; - background-color: #fff; - border-radius: 4px; - position: relative; - margin-bottom: 10px; -} - -.tnotice:before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; -} - -.tnotice__icon { - position: absolute; - top: 50%; - left: 22px; - -webkit-transform: translateY(-50%); - transform: translateY(-50%); - width: 14px; - height: 14px; - padding: 7px; - border-radius: 50%; - display: inline-block; - color: #fff; - text-align: center; - line-height: 11px; -} - -.tnotice__type { - color: #3e3e3e; - font-weight: 700; - margin-top: 0; - margin-bottom: 0; -} - -.tnotice__message { - font-size: 14px; - margin-top: 0; - margin-bottom: 0; - color: #878787; -} - -.tnotice__content { - padding-left: 70px; - padding-right: 60px; -} - -.tnotice__close { - position: absolute; - right: 22px; - top: 50%; - width: 14px; - cursor: pointer; - height: 14px; - fill: #878787; - -webkit-transform: translateY(-50%); - transform: translateY(-50%); -} - -.tnotice--success .tnotice__icon { - background-color: #2bde3f; -} - -.tnotice--success:before { - background-color: #2bde3f; -} - -.tnotice--blue .tnotice__icon { - background-color: #1d72f3; -} - -.tnotice--blue:before { - background-color: #1d72f3; -} - -.tnotice--danger .tnotice__icon { - background-color: #f31e1c; -} - -.tnotice--danger:before { - background-color: #f31e1c; -} - -/*************************** - * Quiz attempts table - **************************/ - -.tutor-quiz-attempt-info-row { - .attempt-view-bottom, - .attempt-view-top { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - .attempt-info-col { - display: -webkit-inline-box; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - max-width: 30%; - } - } - .attempt-view-top { - padding-bottom: 30px; - margin-bottom: 30px; - border-bottom: 1px solid #dcdfe5; - } - .attempt-view-bottom { - margin-bottom: 60px; - .attempt-info-col { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; - } - } - .attempt-user-details { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - .attempt-user-avatar { - padding-right: 20px; - img { - display: block; - width: 70px; - height: 70px; - border-radius: 50%; - } - } - .attempt-info-content h4 { - font-size: 18px; - } - } - .attempt-info-content { - span { - &.result-pass, - &.result-fail { - background: #df3247; - font-size: 14px; - font-weight: 400; - color: #fff; - padding: 1px 4px; - margin-right: 13px; - border-radius: 2px; - } - &.result-pass { - background: var(--tutor-color-success); - } - } - h4, - h5 { - font-size: 14px; - line-height: 25px; - margin: 0; - color: #7a7f85; - font-weight: 400; - } - h4 { - &, - a { - font-weight: 700; - color: var(--tutor-body-color); - margin-top: 7px; - } - } - } -} - -.attempt-review-notice-wrap { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - margin-bottom: 60px; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.attempt-review-notice-wrap p { - margin: 0; - display: -webkit-inline-box; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.attempt-review-notice-wrap p.attempt-review-notice i { - font-size: 16px; - color: #f5c813; - margin-right: 9px; -} - -.attempt-review-notice-wrap p.attempt-review-at > span { - color: var(--tutor-color-primary); - margin-right: 7px; - font-size: 16px; -} - -.attempt-review-notice-wrap p > strong { - font-weight: 400; - margin-right: 5px; -} - -.quiz-attempt-answers-wrap { - table { - th { - background: #fcfcfc; - font-size: 12px; - text-transform: inherit; - } - td { - background-color: #fff; - } - th, - td { - padding: 17px 20px !important; - border-top: 1px solid #eaeaea; - border-bottom: 1px solid #eaeaea; - vertical-align: middle; - text-align: left; - p { - margin: 0; - } - } - - .quiz-manual-review-action { - border: 1px solid #d4dadb; - color: #d4dadb; - height: 30px; - width: 30px; - border-radius: 2px; - font-size: 13px; - display: inline-block; - text-align: center; - line-height: 30px; - -webkit-transition: 300ms; - transition: 300ms; - text-decoration: none; - - &:first-child { - &:hover { - border: 1px solid var(--tutor-color-success); - color: var(--tutor-color-success); - } - } - - &:last-child { - &:hover { - border: 1px solid #df3247; - color: #df3247; - } - } - - &:not(:last-child) { - margin-right: 17px; - } - } - - .quiz-incorrect-answer-text, - .quiz-correct-answer-text { - i { - font-size: 12px; - height: 20px; - width: 20px; - text-align: center; - line-height: 20px; - background: var(--tutor-color-success); - color: #fff; - display: inline-block; - border-radius: 2px; - margin-right: 6px; - } - } - .quiz-incorrect-answer-text i { - background: #df3247; - font-size: 10px; - } - } -} - -/** - Upgrade Notice - */ - -#tutor-update .dummy { - display: none; -} - -#tutor-update .tutor_plugin_update_notice { - padding: 20px 0 !important; -} - -#tutor-update .tutor_plugin_update_notice { - font-weight: 400; - background: #fff8e5 !important; - border-left: 4px solid #ffb900; - border-top: 1px solid #ffb900; - padding: 9px 0 9px 12px !important; - margin: 0 -12px 0 -16px !important; -} - -#tutor-update .tutor_plugin_update_notice .version::before { - content: '\f348'; - display: inline-block; - font: 400 18px/1 dashicons; - speak: none; - margin: 0 8px 0 -2px; - vertical-align: top; -} - -.select2-dropdown.increasezindex { - z-index: 9999999999999; -} - -/*! - * jQuery UI Datepicker 1.8.10 - * http://jqueryui.com - * - * Copyright 2012 jQuery Foundation and other contributors - * Released under the MIT license. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Datepicker#theming - */ - -/** END Calender */ - -/** - * report.css - *Moved report.css from pro add to here - */ - -h2.tutor-page-heading { - background-color: #ffffff; - padding: 30px !important; - margin: 0 !important; - border-bottom: 1px solid #ecedef; -} - -.report-main-wrap h3 { - font-weight: 300; - font-size: 20px; -} - -.tutor-icon-star-full, -.tutor-icon-star-line { - color: #ffd700; -} - -.date-range-input { - position: relative; - margin-right: 10px; -} - -.date-range-input:last-child { - margin-right: 0; -} - -.date-range-input input { - border: 1px solid #d7dadf; - -webkit-box-shadow: none; - box-shadow: none; - line-height: 32px; - margin: 0; - padding-right: 30px; -} - -.date-range-input i.tutor-icon-calendar { - position: absolute; - right: 10px; - top: 13px; -} - -.date-range-input button { - background-color: #3057d5; - color: #ffffff; - border: none; - line-height: 39px; - padding: 0 15px; -} - -/** END Report.css */ - -/** - *END Alert CSS - */ - -/** - Tools Nav - */ - -.tutor-nav-tab-wrapper { - margin-bottom: 10px; -} - -.nav-tab-item { - float: left; - border: 1px solid #ccc; - border-bottom: none; - margin-left: 0.5em; - padding: 10px 14px; - font-size: 14px; - line-height: 1.33; - font-weight: 600; - background: #e5e5e5; - color: #555; - text-decoration: none; - white-space: nowrap; -} - -.nav-tab-item:first-child { - margin-left: 0; -} - -.nav-tab-item:focus, -.nav-tab-item:hover { - background-color: #fff; - color: #444; -} - -.nav-tab-item-active, -.nav-tab-item:focus:active { - -webkit-box-shadow: none; - box-shadow: none; -} - -.nav-tab-item-active { - margin-bottom: -1px; - color: #444; -} - -.nav-tab-item-active, -.nav-tab-item-active:focus, -.nav-tab-item-active:focus:active, -.nav-tab-item-active:hover { - border-bottom: 1px solid #f1f1f1; - background: #f1f1f1; - color: #000; -} - -/** - * END Tools - */ - -.tutor-quiz-feedback-option-option-title { - margin-bottom: 10px !important; -} - -.tutor-quiz-feedback-option-subtitle { - margin: 0 !important; - font-size: 12px; - line-height: 1.67; - color: #505469; -} - -.tutor-quiz-feedback-option-subtitle a { - font-weight: 500; - color: inherit; - text-decoration: underline !important; -} - -/* Fixing course builder css overlap issue */ - -#tutor-instructors h2 { - display: block; -} - -#settings-tab-general .tutor-option-field-row input[type='number'] { - width: 185px; -} - -/* Instructor list layout style */ - -.instructor-layout-templates-fields { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.instructor-layout-template { - max-width: 150px; - padding: 5px; - margin: 3px; -} - -.instructor-layout-template img { - max-width: 100%; - height: auto; - display: block; - border: 6px solid transparent; - -webkit-transition: border-color 400ms; - transition: border-color 400ms; -} - -.instructor-layout-template.selected-template img, -.instructor-layout-template:hover img { - border: 6px solid #3057d6; -} - -/*override default modal on announcement*/ - -.tutor-accouncement-update-modal .modal-header, -.tutor-announcement-create-modal .modal-header { - padding-right: 15px !important; -} - -.tutor-accouncement-update-modal .tutor-modal-content, -.tutor-announcement-create-modal .tutor-modal-content { - border-radius: 20px !important; -} - -/** - * announcement css - * @since v1.7.9 - */ - -.tutor-admin-search-box-container { - display: flex; - justify-content: space-between; - align-items: flex-end; - flex-wrap: wrap; - margin-top: 45px; -} - -.tutor-admin-search-box-container > div:nth-child(1) { - margin-right: 40px; - position: relative; -} - -.tutor-admin-search-box-container > div:nth-child(1) { - flex: 2; -} - -.tutor-admin-search-box-container > div:nth-child(2), -.tutor-admin-search-box-container > div:nth-child(3), -.tutor-admin-search-box-container > div:nth-child(4) { - flex: 1.5; -} - -.tutor-admin-search-box-container > div:not(:last-child) { - margin-right: 30px; -} - -.tutor-admin-search-box-container .tutor-report-search-btn { - position: absolute; - width: 40px; - height: 40px; - bottom: 0; - right: 0; - border: 0; - background: transparent; - color: #3e64de; - font-size: 20px; - cursor: pointer; - outline: none; -} - -.tutor-admin-search-box-container > div:nth-child(1) input { - /* height: 50px; */ - padding-right: 45px; -} - -.tutor-admin-search-box-container input[type='text'], -.tutor-admin-search-box-container select { - width: 100%; - height: 40px; - border-radius: 3px; - border: solid 1px #dcdce1; - background-color: #ffffff; - padding: 0 14px; - transition: 0.2s; -} - -.tutor-admin-search-box-container .date-range-input i.tutor-icon-calendar { - position: absolute; - width: 42px; - height: 40px; - right: 0; - top: 0; - color: #3e64de; - font-size: 18px; - text-align: center; - line-height: 40px; -} - -.tutor-admin-search-box-container .menu-label { - font-size: 14px; - font-weight: 400; - color: #737787; - margin-bottom: 7px; -} - -.tutor-admin-search-box-container > div:nth-child(4) input::-webkit-input-placeholder { - color: #3f435b; - font-size: 15px; -} - -.tutor-admin-search-box-container > div:nth-child(1) input::-webkit-input-placeholder { - font-size: 16px; - font-weight: 400; - color: #737787; -} - -.tutor-admin-search-box-container input[type='text']:hover, -.tutor-admin-search-box-container input[type='text']:focus, -.tutor-admin-search-box-container select:hover, -.tutor-admin-search-box-container select:focus, -.tutor-date-range-wrap .date-range-input input:hover, -.tutor-date-range-wrap .date-range-input input:focus { - border-color: var(--tutor-color-primary) !important; - box-shadow: none !important; - outline: none !important; -} - -@media (max-width: 767px) { - .tutor-admin-search-box-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - grid-gap: 15px; - } - .tutor-admin-search-box-container > div { - margin-right: 0 !important; - } -} - -@media (max-width: 991px) { - .tutor-list-wrap { - overflow-x: scroll; - } -} - -/** - * Quiz attempt page css - * @since 1.9.5 - */ - -.tutor-sorting-bulk-action-wrapper { - display: flex; - justify-content: space-between; - align-items: flex-end; - padding: 0px 0px 30px 0px; -} - -.tutor-sorting-bulk-action-wrapper .tutor-admin-search-box-container { - width: 70%; -} - -.tutor-admin-search-box-container .tutor-search-form-group { - flex: 18% !important; -} - -.report-course-list-wrap .detail .status span { - font-size: 14px; - font-weight: 300; - line-height: 1; - color: #737787; - margin-left: 25px; - padding-left: 14px; - position: relative; - display: inline-flex; - align-items: center; -} - -.report-course-list-wrap .detail .status span::before { - content: ''; - position: absolute; - width: 8px; - height: 8px; - background: #b9bac3; - border-radius: 50%; - left: 0; -} - -.report-course-list-wrap .detail .status span { - margin-left: 0; - padding-left: 16px; -} - -.report-course-list-wrap .detail .status span::before { - width: 6px; - height: 6px; -} - -.report-course-list-wrap .detail .status .running::before { - background-color: #3e64de; -} - -.report-course-list-wrap .detail .status .complete::before { - background-color: #7bbc30; -} - -.report-course-list-wrap .detail .heading { - font-size: 16px; - line-height: 1.75; - color: #3f435b; - margin-bottom: 10px; -} - -.report-course-list-wrap .detail { - padding: unset !important; - text-align: left !important; - font-size: 14px !important; - font-weight: 400 !important; -} - -.report-course-list .course-list-details { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 1fr; - grid-gap: 20px; - grid-template-columns: repeat(3, 1fr); -} - -.tutor-list-wrap .no-data-found { - display: flex; - align-items: center; - padding: 0 0 30px 0; -} - -//a-to-z-sorting icon -.tutor-table-rows-sorting { - cursor: pointer; -} - -.a-to-z-sort-icon { - cursor: pointer; -} - -.tutor-entry-content { - > br { - display: none; - } - p:not(:last-child) { - padding-bottom: 20px; - word-wrap: break-word; - } -} - -/* .tutor-table.qna-list-table */ -.tutor-table.qna-list-table { - .tutor-form-feedback.tutor-qna-question-col { - margin-top: 0; - p { - margin: 0; - } - } - a:focus{ - outline: none; - box-shadow: none; - } -} -#tutor-quiz-question-wrapper { - .mce-branding { - display: none; - } -} - -.tutor-pro-badge{ - background: #e5803c; - color: #fff; - font-weight: 400; - border-radius: 16px; - padding: 1px 6px; - font-size: 11px; - display: inline-block; - line-height: 15px; -} - -.tutor-new-menu-badge { - display: inline-block; - font-size: 11px; - line-height: 16px; - font-weight: 400; - color: #fff; - border: 1px solid #596369; - border-radius: 11px; - padding: 0px 6px; -} - -.wp-submenu li a[href="admin.php?page=create-course"] { - display: none!important; -} - -.tutor-form-check-input.tutor-bulk-checkbox, -.tutor-form-check-input#tutor-bulk-checkbox-all { - height: 20px; - width: 20px; -} - -.tutor-text-ellipsis-2-lines { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.tutor-dashboard-list-table { - tr { - th { - border-top: 1px solid #E6E6E6; - border-bottom: 1px solid #F5F5F5; - font-weight: 400; - color: #636363; - background-color: #ffffff; - padding: 14px 12px; - - &:first-child { - border-left: 1px solid #E6E6E6; - padding: 16px; - - .rtl & { - border-left: none; - border-right: 1px solid #E6E6E6; - } - } - - &:last-child { - border-right: 1px solid #E6E6E6; - - .rtl & { - border-right: none; - border-left: 1px solid #E6E6E6; - } - } - - &:nth-child(2) { - padding-left: 0px; - } - } - - td { - border-bottom: 1px solid #F5F5F5; - padding: 16px 12px; - - &:first-child { - border-left: 1px solid #E6E6E6; - padding: 16px; - - .rtl & { - border-left: none; - border-right: 1px solid #E6E6E6; - } - } - - &:last-child { - border-right: 1px solid #E6E6E6; - - .rtl & { - border-right: none; - border-left: 1px solid #E6E6E6; - } - } - - &:nth-child(2) { - padding-left: 0px; - } - } - - &:last-child td { - border-bottom: 1px solid #E6E6E6; - } - } - - .tutor-form-check-input { - border-width: 1px; - } - - .tutor-avatar { - box-shadow: none; - } -} - -.tutor-backend .notice { - margin-left: 0px; - margin-right: 20px; -} diff --git a/assets/scss/admin-dashboard/template-import.scss b/assets/scss/admin-dashboard/template-import.scss deleted file mode 100644 index 39b54171f9..0000000000 --- a/assets/scss/admin-dashboard/template-import.scss +++ /dev/null @@ -1,509 +0,0 @@ -body.tutor-backend-tutor-templates.tutor-lms-pro_page_tutor-templates { - background-color: #f1f1f1; -} - -.tutor-template-preview-device-switcher { - li { - cursor: pointer; - - svg:nth-child(1) { - display: block; - } - - svg:nth-child(2) { - display: none; - } - - &.active { - svg:nth-child(1) { - display: none; - } - - svg:nth-child(2) { - display: block; - } - } - } -} - -.tutor-template-import-wrapper { - img { - width: 100%; - } - - .tutor-template-device-selector { - margin-bottom: 16px; - display: flex; - gap: 10px; - justify-content: center; - } - - .tutor-template-empty-state { - margin-top: 30px; - font-size: 20px; - line-height: 1.5; - } - - // color palette - .all-colors-wrapper { - flex-direction: row; - flex-wrap: wrap; - gap: 2px; - } - - .color-palette { - width: unset !important; - display: grid; - gap: 0px; - grid-template-columns: 1fr 1fr; - grid-template-areas: ". ." ". ."; - border-radius: 6px; - overflow: hidden; - cursor: pointer; - border: 2px solid rgb(0 0 0 / 5%); - box-shadow: rgba(255, 255, 255, 0) 0px 0px 0px 2px; - } - - .color-palette.active { - border: 2px solid #5641f3; - box-shadow: rgba(255, 255, 255, 1) 0px 0px 0px 2px; - } - - [data-index="0"] { - grid-row-start: span 2; - grid-row-end: span 2; - grid-column-start: span 1; - grid-column-end: span 1; - width: 17px; - height: 18px; - } - - [data-index="1"] { - width: 17px; - height: 9px; - } - - [data-index="2"] { - width: 17px; - height: 9px; - } - - // tutor template shimmer effect - .tutor-template-shimmer-effect { - position: absolute; - inset: 0; - background: #fff; - } - - .tutor-template-shimmer-effect-2 { - display: flex; - flex-direction: column; - gap: 10px; - overflow: hidden; - - >div { - background: #ddd; - border-radius: 6px; - height: 25px; - position: relative; - - &::after { - content: ''; - position: absolute; - top: 0; - left: -150px; - width: 150px; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); - animation: tutorTemplateShimmerKeyframe 1.5s infinite; - } - } - - .tutor-template-shimmer-effect-2-box-2 { - width: 60%; - height: 20px; - } - - .tutor-template-shimmer-effect-2-box-3 { - width: 80%; - } - } - - .tutor-template-shimmer-box { - background-color: #e0e0e0; - border-radius: 8px; - position: relative; - overflow: hidden; - display: block; - } - - .tutor-template-shimmer-box::after { - content: ''; - position: absolute; - top: 0; - left: -150px; - width: 150px; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); - animation: tutorTemplateShimmerKeyframe 1.5s infinite; - } - - .tutor-template-shimmer-box-large { - height: 50%; - margin-bottom: 10px; - } - - .tutor-template-shimmer-row { - display: flex; - gap: 10px; - height: calc(50% - 10px); - } - - .tutor-template-shimmer-box-small { - flex: 1; - height: 100%; - } - - @keyframes tutorTemplateShimmerKeyframe { - 0% { - left: -150px; - } - - 100% { - left: 100%; - } - } -} - -.tutor-text-truncate { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.tutor-import-item-content-title { - display: flex; - gap: 10px; -} - -.tutor-template-import-area { - max-width: 1200px; - margin: 0 auto; -} - -.tutor-template-import-area-top { - .tutor-template-import-area-top-left { - .tutor-template-area-top-left-icon { - width: 40px; - } - } - - .tutor-template-top-left-heading { - line-height: 28px; - } - - .tutor-template-area-left-text { - line-height: 24px; - margin-top: 2px; - } - - .tutor-template-import-area-top-right { - .tutor-template-search-wrapper { - position: relative; - - svg.tutor-template-search-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - } - } - - input { - width: 300px; - height: 40px; - padding: 8px 8px 8px 30px; - gap: 4px; - border-radius: 6px; - border: 1px solid #c3c5cb; - opacity: 0px; - - &::placeholder { - color: #5b616f; - font-size: 16px; - font-weight: 400; - line-height: 24px; - text-align: left; - } - } - } -} - -.tutor-template-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(374px, 1fr)); - gap: 24px; - - @media (max-width: 1370px) { - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - } - - @media (max-width: 1170px) { - grid-template-columns: repeat(auto-fit, minmax(274px, 1fr)); - } - - .tutor-template-list-single-template { - border-radius: 6px; - border: 1px solid #e0e2ea; - background: white; - min-height: 350px; - position: relative; - - &:hover { - box-shadow: 0px 6px 20px 0px #1c31681a; - } - - .tutor-template-coming-soon { - font-size: 14px; - background: #efefef; - padding: 2px 8px; - border-radius: 4px; - } - - .tutor-template-list-single-template-inner { - position: relative; - - .tutor-import-template-preview-img { - img { - border-radius: 6px; - } - } - } - - .tutor-template-list-single-template-footer { - - .tutor-import-template-name { - line-height: 26px; - } - - a.tutor-template-preview { - display: flex; - - &:hover { - svg { - path { - fill: #3e64de; - } - } - } - } - } - } -} - -// template preview scss. -.tutor-template-preview-modal { - display: none; - position: fixed; - z-index: 999999; - inset: 0; - - .tutor-template-preview-modal-overlay { - width: 100%; - height: 100%; - position: absolute; - inset: 0; - background: #161616B2; - backdrop-filter: blur(20px); - } - - .tutor-template-preview-modal-back-link { - width: max-content; - cursor: pointer; - - i { - width: 22px; - height: 22px; - border: 1px solid #C3C5CB; - color: #9197A8; - border-radius: 2.25px; - display: flex; - align-items: center; - justify-content: center; - } - - &:hover { - i { - color: var(--tutor-color-primary); - border: 1px solid var(--tutor-color-primary); - } - - div { - font-weight: 500; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.1px; - vertical-align: middle; - color: #000; - } - } - - div { - font-weight: 500; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.1px; - vertical-align: middle; - color: #212327; - } - } -} - -.tutor-template-preview-frame { - width: 100%; - position: relative; - max-width: 1320px; - margin: auto auto; - overflow: auto; - display: flex; - flex-direction: column; - border-radius: 12px; - background: #fff; - padding: 0px 10px 10px 10px; - min-height: 400px; -} - -.tutor-template-preview-frame-header { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - display: grid; - grid-template-columns: 1fr 1fr; - padding: 14px 260px 14px 0px; - - .rtl & { - padding: 14px 0px 14px 260px; - } - - h3 { - margin: 0; - } - - ul { - display: flex; - gap: 15px; - margin: 0; - align-items: center; - - i { - font-size: 20px; - cursor: pointer; - color: #33333360; - } - - li { - margin: 0; - - &.active { - i { - color: #3e64de; - } - } - } - } -} - -.tutor-template-preview-close-modal { - font-size: 20px; - cursor: pointer; - color: #333; - z-index: 1000; -} - -.tutor-template-preview-iframe-wrapper { - width: 100%; - height: calc(100vh - 80px); - display: grid; - grid-template-columns: 1fr 180px; - - .tutor-preview-template-name { - font-weight: 500; - font-size: 16px; - line-height: 26px; - letter-spacing: 0px; - vertical-align: middle; - } - - .tutor-droip-color-presets-heading { - font-weight: 400; - font-size: 13px; - line-height: 18px; - letter-spacing: 0px; - vertical-align: middle; - margin: 0px 0px 5px 0px - } - - #droip-color-modes { - padding: 10px 0px 0px 0px; - display: none; - } - - .tutor-template-preview-iframe-parent { - position: relative; - background-color: #F8F8F8; - text-align: center; - border-radius: 8px; - overflow: hidden; - } - - #tutor-template-preview-iframe { - width: 1400px; - height: calc(100% / 0.8); - transform: scale(0.8); - transform-origin: left top; - } - - .tutor-template-preview-import-area { - display: flex; - justify-content: center; - padding-left: 20px; - - .rtl & { - padding-left: 0px; - padding-right: 20px; - } - } -} - -.tutor-template-import-btn-wrapper { - margin-top: 10px; - - button { - width: 100%; - position: relative; - - svg { - width: 15px; - height: 15px; - position: absolute; - right: 0; - top: 0; - transform: translateX(50%) translateY(-50%); - } - } -} - -.tutor-include-demo-courses-toggle { - margin-top: 20px; - gap: 10px; - .tutor-form-check-input { - width: 19px; - height: 19px; - margin-top: -3px; - } - label { - margin-top: -7px; - font-size: 13px; - } -} diff --git a/assets/scss/front/course/_social-share.scss b/assets/scss/front/course/_social-share.scss deleted file mode 100644 index 3266394907..0000000000 --- a/assets/scss/front/course/_social-share.scss +++ /dev/null @@ -1,33 +0,0 @@ -.tutor-social-share-wrap { - button { - margin-right: 16px; - border: none; - padding: 8px 15px; - border-radius: 6px; - color: white; - cursor: pointer; - display: inline-flex; - align-items: center; - @include breakpoint-max(mobile) { - width: 150px; - justify-content: center; - &:not(:first-child) { - margin-top: 10px; - } - } - span { - font-style: normal; - font-weight: 500; - font-size: 15px; - color: #ffffff; - position: relative; - top: 1px; - left: 2px; - } - } - .tutor_share { - &.s_linkedin i { - margin-top: -2px; - } - } -} diff --git a/assets/react/admin-dashboard/quiz-attempts.js b/assets/src/js/admin-dashboard/quiz-attempts.js similarity index 100% rename from assets/react/admin-dashboard/quiz-attempts.js rename to assets/src/js/admin-dashboard/quiz-attempts.js diff --git a/assets/react/admin-dashboard/segments/color-preset.js b/assets/src/js/admin-dashboard/segments/color-preset.js similarity index 100% rename from assets/react/admin-dashboard/segments/color-preset.js rename to assets/src/js/admin-dashboard/segments/color-preset.js diff --git a/assets/react/admin-dashboard/segments/column-filter.js b/assets/src/js/admin-dashboard/segments/column-filter.js similarity index 100% rename from assets/react/admin-dashboard/segments/column-filter.js rename to assets/src/js/admin-dashboard/segments/column-filter.js diff --git a/assets/src/js/admin-dashboard/segments/consent-logs.js b/assets/src/js/admin-dashboard/segments/consent-logs.js new file mode 100644 index 0000000000..375afd5d0b --- /dev/null +++ b/assets/src/js/admin-dashboard/segments/consent-logs.js @@ -0,0 +1,187 @@ +/** + * Consent logs modal functionality for students and instructors. + * + * @since 4.0.0 + */ +const AJAX_URL = window.ajaxurl || window._tutorobject?.ajaxurl || ''; +const AJAX_ACTION = 'tutor_user_consents'; +const NONCE_KEY = window._tutorobject?.nonce_key || '_tutor_nonce'; +const NONCE_VALUE = window._tutorobject?.[NONCE_KEY] || ''; +const overlay = document.getElementById('tutor-consent-logs-modal'); +const modalBody = overlay?.querySelector('.tutor-consent-logs-modal-body'); +const downloadBtn = overlay?.querySelector('[data-consent-logs-download]'); +const userNameEl = overlay?.querySelector('.tutor-consent-user-card-name'); +const userJoinedEl = overlay?.querySelector('.tutor-consent-user-card-joined'); +const userAvatarEl = overlay?.querySelector('.tutor-consent-user-card img'); + +// Current open state. +let currentUserId = 0; +let currentUserName = ''; +let currentUserJoined = ''; +let currentUserEmail = ''; +let currentUserLogin = ''; +let currentLogs = []; + +const { __ } = wp.i18n; + +/** + * Build a human-readable title from the consent log. + * + * @param log - The consent log object + * @param includeAccepted - Whether to prefix the title with "Accepted" + * @returns A human-readable title string + */ +const getLogTitle = (log, includeAccepted = true) => { + if (log.consent_title) { + return includeAccepted + ? `${__('Accepted', 'tutor')} ${log.consent_title}` + : log.consent_title; + } + + return includeAccepted + ? __('Accepted Consent', 'tutor') + : __('Consent', 'tutor'); +}; + +const renderTimeline = (logs) => { + const items = logs.map((log, index) => { + const title = getLogTitle(log); + const date = log.created_at_gmt || ''; + const ago = log.timeAgo || log.time_ago || ''; + const ip = log.ip_address ? `IP: ${log.ip_address}` : ''; + const source = log.source ? `Source: ${log.source}` : ''; + const agent = log.user_agent ? `Agent: ${log.user_agent}` : ''; + const metaLines = [ip, source, agent].filter(Boolean); + return ` + + `; + }); + return items.join(''); +}; + +const showLoading = () => { + if (!modalBody) return; + modalBody.innerHTML = `
${__('Loading…', 'tutor')}
`; + if (downloadBtn) downloadBtn.style.display = 'none'; +}; + +const showEmpty = () => { + if (!modalBody) return; + modalBody.innerHTML = `
${__('No consent logs found.', 'tutor')}
`; + if (downloadBtn) downloadBtn.style.display = 'none'; +}; + +const fetchAndRender = (userId, userName, userJoined, avatarSrc, userEmail, userLogin) => { + if (!overlay || !modalBody) return; + currentUserId = userId; + currentUserName = userName; + currentUserJoined = userJoined; + currentUserEmail = userEmail; + currentUserLogin = userLogin; + + // Show loading state and hide download button. + showLoading(); + + // Fill user card. + if (userNameEl) userNameEl.textContent = userName; + if (userJoinedEl) userJoinedEl.textContent = userJoined ? `${__('Joined', 'tutor')} ${userJoined}` : ''; + if (userAvatarEl && avatarSrc) userAvatarEl.src = avatarSrc; + + const body = new FormData(); + body.append('action', AJAX_ACTION); + body.append('user_action', 'all_consents_given_by_user'); + body.append('user_id', userId); + body.append(NONCE_KEY, NONCE_VALUE); + + fetch(AJAX_URL, { method: 'POST', body }) + .then((r) => r.json()) + .then((data) => { + const logs = data.data; + currentLogs = logs; + if (!logs.length) { + showEmpty(); + return; + } + const userCard = ` + + `; + modalBody.innerHTML = ` + + ${userCard} + `; + if (downloadBtn) downloadBtn.style.display = ''; + }) + .catch(() => showEmpty()); +}; + +const downloadCSV = () => { + if (!currentLogs.length) return; + + const studentInfo = [ + ['Name:', currentUserName], + ['User Name:', currentUserLogin], + ['Email:', currentUserEmail], + ['Joined At:', currentUserJoined], + [], + ]; + + const headers = ['Title', 'Date (UTC)', 'IP Address', 'Source', 'User Agent']; + + const rows = currentLogs.map((log) => [ + getLogTitle(log, false), + log.created_at_gmt || '', + log.ip_address || '', + log.source || '', + log.user_agent || '', + ]); + + const escape = (v) => `"${String(v).replace(/"/g, '""')}"`; + + const csv = [...studentInfo, headers, ...rows] + .map((row) => row.map(escape).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `consent-logs-${currentUserName}.csv`; + a.click(); + URL.revokeObjectURL(url); +}; + +const initConsentLogTriggers = () => { + document.querySelectorAll('[data-consent-logs-trigger]').forEach((btn) => { + btn.addEventListener('click', () => { + // Hide download button immediately before modal opens. + if (downloadBtn) downloadBtn.style.display = 'none'; + + const userId = btn.dataset.userId || ''; + const userName = btn.dataset.userName || ''; + const userJoined = btn.dataset.userJoined || ''; + const avatarSrc = btn.dataset.avatarSrc || ''; + const userEmail = btn.dataset.userEmail || ''; + const userLogin = btn.dataset.userLogin || ''; + fetchAndRender(userId, userName, userJoined, avatarSrc, userEmail, userLogin); + }); + }); +}; + +if (downloadBtn) downloadBtn.addEventListener('click', downloadCSV); +document.addEventListener('DOMContentLoaded', initConsentLogTriggers); diff --git a/assets/react/admin-dashboard/segments/editor_full.js b/assets/src/js/admin-dashboard/segments/editor_full.js similarity index 100% rename from assets/react/admin-dashboard/segments/editor_full.js rename to assets/src/js/admin-dashboard/segments/editor_full.js diff --git a/assets/react/admin-dashboard/segments/filter.js b/assets/src/js/admin-dashboard/segments/filter.js similarity index 100% rename from assets/react/admin-dashboard/segments/filter.js rename to assets/src/js/admin-dashboard/segments/filter.js diff --git a/assets/react/admin-dashboard/segments/image-preview.js b/assets/src/js/admin-dashboard/segments/image-preview.js similarity index 100% rename from assets/react/admin-dashboard/segments/image-preview.js rename to assets/src/js/admin-dashboard/segments/image-preview.js diff --git a/assets/react/admin-dashboard/segments/import-export.js b/assets/src/js/admin-dashboard/segments/import-export.js similarity index 98% rename from assets/react/admin-dashboard/segments/import-export.js rename to assets/src/js/admin-dashboard/segments/import-export.js index d822caeccc..41aac7d376 100644 --- a/assets/react/admin-dashboard/segments/import-export.js +++ b/assets/src/js/admin-dashboard/segments/import-export.js @@ -38,18 +38,18 @@ function tutor_option_history_load(dataset) {
  • - + ${__('Download', 'tutor')}
  • - + ${__('Delete', 'tutor')}
  • diff --git a/assets/src/js/admin-dashboard/segments/legal-consents.js b/assets/src/js/admin-dashboard/segments/legal-consents.js new file mode 100644 index 0000000000..9b1bf199df --- /dev/null +++ b/assets/src/js/admin-dashboard/segments/legal-consents.js @@ -0,0 +1,790 @@ +const { __ } = wp.i18n; + +const SELECTORS = { + legalConsentPage: 'legal_consents', + headerSaveButton: 'save_tutor_option', + tutorOptionForm: 'tutor-option-form', + tabLinks: '[tutor-option-tabs] a', + tabPages: '.tutor-option-nav-page', + nonceInput: 'input[name="_tutor_nonce"]', + legalConsentsContainer: '[data-legal-consents]', + consentList: '[data-consent-list]', + consentTemplate: '[data-consent-template]', + consentCard: '[data-consent-card]', + consentEmptyState: '[data-consent-empty-state]', + consentFooter: '[data-consent-footer]', + addConsent: '[data-add-consent]', + consentTitleDisplay: '[data-consent-title]', + consentTitleInput: '[data-consent-title-input]', + consentEnabled: '[data-consent-enabled]', + consentEnabledHidden: '[data-consent-enabled-hidden]', + consentToggleButton: '[data-consent-toggle]', + consentDeleteButton: '[data-consent-delete]', + consentCancelButton: '[data-consent-cancel]', + consentSaveButton: '[data-consent-save]', + consentMessageTextarea: 'textarea[name="consent_message"]', + consentMethodSelect: 'select[name="consent_method"]', + displayOnCheckboxes: '[name="display_on[]"]', + pageDropdownToggle: '[data-page-dropdown-toggle]', + pageDropdown: '[data-page-dropdown]', + pageButton: '[data-page-btn]', +}; + +const CSS_CLASSES = { + active: 'is-active', + collapsed: 'is-collapsed', + loading: 'is-loading', +}; + +const ARIA = { + expanded: 'aria-expanded', +}; + +const DATA_ATTRIBUTES = { + consentId: 'consentId', + nextIndex: 'nextIndex', + pageKey: 'pageKey', +}; + +const AJAX_ACTIONS = { + legalConsents: 'tutor_gdpr_legal_consents', +}; + +const CRUD_ACTIONS = { + create: 'create', + update: 'update', + delete: 'delete', +}; + +const FORM_FIELDS = { + action: 'action', + crudAction: 'crud_action', + nonce: '_tutor_nonce', + id: 'id', + title: 'consent_title', + message: 'consent_message', + displayOn: 'display_on', + method: 'consent_method', + consentMap: 'consent_map', + isActive: 'is_active', +}; + +const DEFAULT_CONSENT_TITLE = __('Consent Title', 'tutor'); +const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_-]+)\}/g; +const TEMPLATE_INDEX_PLACEHOLDER = '__INDEX__'; +const TUTOR_OPTION_SAVED_EVENT = 'tutor_option_saved'; + +const getNonceValue = () => document.querySelector(SELECTORS.nonceInput)?.value || ''; + +const getLegalConsentPage = () => document.getElementById(SELECTORS.legalConsentPage); + +const getHeaderSaveButton = () => document.getElementById(SELECTORS.headerSaveButton); + +const showToast = (title, message, type = 'success') => { + if (typeof window.tutor_toast === 'function') { + window.tutor_toast(title, message, type); + } +}; + +const getResponseMessage = (data, fallbackMessage) => { + if (typeof data?.message === 'string' && data.message.length) { + return data.message; + } + + if (typeof data?.data === 'string' && data.data.length) { + return data.data; + } + + return fallbackMessage; +}; + +const getValidationErrors = (data) => { + const validationError = data?.data?.validation_error; + if (!validationError || typeof validationError !== 'object') { + return null; + } + + return Object.entries(validationError) + .map(([field, message]) => `${field}: ${message}`) + .join('\n'); +}; + +const isSuccessfulResponse = (data) => Boolean( + data?.success === true + || (typeof data?.status_code === 'number' && data.status_code >= 200 && data.status_code < 300) +); + +const isLegalConsentsPageActive = () => { + const page = getLegalConsentPage(); + return !!page && page.classList.contains(CSS_CLASSES.active); +}; + +const syncFooterSaveButtons = () => { + const page = getLegalConsentPage(); + const headerSaveButton = getHeaderSaveButton(); + + if (!page || !headerSaveButton) { + return; + } + + const isLoading = headerSaveButton.classList.contains(CSS_CLASSES.loading); + page.querySelectorAll(SELECTORS.consentSaveButton).forEach((button) => { + button.classList.toggle(CSS_CLASSES.loading, isLoading); + }); +}; + +const toggleHeaderSaveVisibility = () => { + const headerSaveButton = getHeaderSaveButton(); + + if (!headerSaveButton) { + return; + } + + headerSaveButton.style.display = isLegalConsentsPageActive() ? 'none' : ''; +}; + +const markSettingsAsChanged = () => { + const headerSaveButton = getHeaderSaveButton(); + + if (!headerSaveButton) { + return; + } + + headerSaveButton.removeAttribute('disabled'); + syncFooterSaveButtons(); +}; + +const updateConsentTitle = (card, value) => { + const title = card.querySelector(SELECTORS.consentTitleDisplay); + if (title) { + title.textContent = value.trim() || DEFAULT_CONSENT_TITLE; + } +}; + +const syncEmptyState = (container) => { + const list = container?.querySelector(SELECTORS.consentList); + const emptyState = container?.querySelector(SELECTORS.consentEmptyState); + const footer = container?.querySelector(SELECTORS.consentFooter); + + if (!list || !emptyState) { + return; + } + + const hasCards = list.querySelectorAll(SELECTORS.consentCard).length > 0; + + emptyState.hidden = hasCards; + + if (footer) { + footer.hidden = !hasCards; + } +}; + +const getCardFormState = (card) => ({ + title: card.querySelector(SELECTORS.consentTitleInput)?.value || '', + enabled: Boolean(card.querySelector(SELECTORS.consentEnabled)?.checked), + message: card.querySelector(SELECTORS.consentMessageTextarea)?.value || '', + method: card.querySelector(SELECTORS.consentMethodSelect)?.value || '', + displayOn: Array.from(card.querySelectorAll(SELECTORS.displayOnCheckboxes)) + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.value) + .sort(), + consentMap: getSelectedPagesMap(card), +}); + +const getSelectedPagesMap = (card) => { + const messageValue = card.querySelector(SELECTORS.consentMessageTextarea)?.value || ''; + const usedPlaceholders = new Set(Array.from(messageValue.matchAll(PLACEHOLDER_PATTERN), (match) => match[1])); + const selectedPages = {}; + + card.querySelectorAll(SELECTORS.pageButton).forEach((button) => { + const pageKey = button.dataset[DATA_ATTRIBUTES.pageKey]; + if (pageKey && usedPlaceholders.has(pageKey)) { + selectedPages[pageKey] = button.value; + } + }); + + return selectedPages; +}; + +const isSameCardState = (left, right) => { + if (!left || !right) { + return false; + } + + return left.title === right.title + && left.enabled === right.enabled + && left.message === right.message + && left.method === right.method + && JSON.stringify(left.displayOn) === JSON.stringify(right.displayOn) + && JSON.stringify(left.consentMap) === JSON.stringify(right.consentMap); +}; + +const isValidConsentState = (state) => Boolean( + state.title.trim() + && state.message.trim() + && state.method + && state.displayOn.length > 0 +); + +const applyCardState = (card, state) => { + const titleInput = card.querySelector(SELECTORS.consentTitleInput); + const enabledInput = card.querySelector(SELECTORS.consentEnabled); + const enabledHiddenInput = card.querySelector(SELECTORS.consentEnabledHidden); + const messageInput = card.querySelector(SELECTORS.consentMessageTextarea); + const methodSelect = card.querySelector(SELECTORS.consentMethodSelect); + + if (titleInput) { + titleInput.value = state.title || ''; + updateConsentTitle(card, titleInput.value); + } + + if (enabledInput) { + enabledInput.checked = Boolean(state.enabled); + } + + if (enabledHiddenInput) { + enabledHiddenInput.value = state.enabled ? 'on' : 'off'; + } + + if (messageInput) { + messageInput.value = state.message || ''; + messageInput.dispatchEvent(new Event('input', { bubbles: true })); + } + + if (methodSelect) { + methodSelect.value = state.method || methodSelect.value; + } + + card.querySelectorAll(SELECTORS.displayOnCheckboxes).forEach((checkbox) => { + checkbox.checked = state.displayOn.includes(checkbox.value); + }); +}; + +const syncCardSaveButton = (card, savedState) => { + const saveButton = card.querySelector(SELECTORS.consentSaveButton); + if (!saveButton || saveButton.classList.contains(CSS_CLASSES.loading)) { + return; + } + + saveButton.disabled = isSameCardState(getCardFormState(card), savedState); +}; + +const syncCardDiscardButton = (card, savedState) => { + const discardButton = card.querySelector(SELECTORS.consentCancelButton); + if (!discardButton || discardButton.classList.contains(CSS_CLASSES.loading)) { + return; + } + + discardButton.disabled = isSameCardState(getCardFormState(card), savedState); +}; + +const toggleConsentCard = (card, collapsed) => { + if (0 === Number(card.dataset[DATA_ATTRIBUTES.consentId] || 0)) { + return; + } + + card.classList.toggle(CSS_CLASSES.collapsed, collapsed); + + const toggleButton = card.querySelector(SELECTORS.consentToggleButton); + if (toggleButton) { + toggleButton.setAttribute(ARIA.expanded, String(!collapsed)); + } +}; + +const closePageDropdown = (dropdown, toggleButton) => { + if (!dropdown) { + return; + } + + dropdown.hidden = true; + + if (toggleButton) { + toggleButton.setAttribute(ARIA.expanded, 'false'); + } +}; + +const closeAllPageDropdowns = (scope = document, exceptDropdown = null) => { + scope.querySelectorAll(SELECTORS.pageDropdown).forEach((dropdown) => { + if (dropdown === exceptDropdown) { + return; + } + + closePageDropdown(dropdown, dropdown.parentElement?.querySelector(SELECTORS.pageDropdownToggle)); + }); +}; + +const bindPageLinkControl = (card, onCardChange) => { + const fieldInput = card.querySelector(SELECTORS.pageDropdownToggle)?.closest('.tutor-option-field-input'); + const toggleButton = fieldInput?.querySelector(SELECTORS.pageDropdownToggle); + const dropdown = fieldInput?.querySelector(SELECTORS.pageDropdown); + const textarea = fieldInput?.querySelector(SELECTORS.consentMessageTextarea); + const pageButtons = fieldInput?.querySelectorAll(SELECTORS.pageButton) || []; + + if (!fieldInput || !toggleButton || !dropdown || !textarea) { + return; + } + + const syncPageButtons = () => { + pageButtons.forEach((button) => { + const pageKey = button.dataset[DATA_ATTRIBUTES.pageKey] || ''; + const placeholder = pageKey ? `{${pageKey}}` : ''; + const isSelected = Boolean(placeholder && textarea.value.includes(placeholder)); + + button.disabled = isSelected; + button.classList.toggle('is-selected', isSelected); + }); + }; + + toggleButton.addEventListener('click', (event) => { + event.stopPropagation(); + + const isOpening = dropdown.hidden; + closeAllPageDropdowns(card.closest(SELECTORS.legalConsentsContainer) || document, isOpening ? dropdown : null); + dropdown.hidden = !isOpening; + toggleButton.setAttribute(ARIA.expanded, String(isOpening)); + }); + + pageButtons.forEach((button) => { + button.addEventListener('click', () => { + const pageKey = button.dataset[DATA_ATTRIBUTES.pageKey]; + + if (!pageKey) { + return; + } + + const placeholder = `{${pageKey}}`; + const currentValue = textarea.value.trim(); + + if (!currentValue.includes(placeholder)) { + textarea.value = currentValue ? `${currentValue} ${placeholder}` : placeholder; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + + syncPageButtons(); + closePageDropdown(dropdown, toggleButton); + }); + }); + + textarea.addEventListener('input', () => { + syncPageButtons(); + onCardChange(); + }); + + syncPageButtons(); +}; + +const buildSavePayload = ({ card, consentId, enabledInput, savedState }) => { + const currentState = getCardFormState(card); + const payload = new FormData(); + + payload.append(FORM_FIELDS.action, AJAX_ACTIONS.legalConsents); + payload.append(FORM_FIELDS.crudAction, consentId ? CRUD_ACTIONS.update : CRUD_ACTIONS.create); + payload.append(FORM_FIELDS.nonce, getNonceValue()); + + if (consentId) { + payload.append(FORM_FIELDS.id, String(consentId)); + } + + const fields = { + [FORM_FIELDS.title]: currentState.title, + [FORM_FIELDS.message]: currentState.message, + [FORM_FIELDS.displayOn]: currentState.displayOn.join(','), + [FORM_FIELDS.method]: currentState.method, + [FORM_FIELDS.consentMap]: JSON.stringify(currentState.consentMap), + [FORM_FIELDS.isActive]: enabledInput?.checked ? '1' : '0', + }; + + if (!consentId) { + Object.entries(fields).forEach(([key, value]) => { + payload.append(key, value); + }); + + return payload; + } + + const previousDisplayOn = savedState.displayOn.join(','); + const previousConsentMap = JSON.stringify(savedState.consentMap); + + if (fields[FORM_FIELDS.title] !== savedState.title) { + payload.append(FORM_FIELDS.title, fields[FORM_FIELDS.title]); + } + + if (fields[FORM_FIELDS.message] !== savedState.message) { + payload.append(FORM_FIELDS.message, fields[FORM_FIELDS.message]); + } + + if (fields[FORM_FIELDS.displayOn] !== previousDisplayOn) { + payload.append(FORM_FIELDS.displayOn, fields[FORM_FIELDS.displayOn]); + } + + if (fields[FORM_FIELDS.method] !== savedState.method) { + payload.append(FORM_FIELDS.method, fields[FORM_FIELDS.method]); + } + + if (fields[FORM_FIELDS.consentMap] !== previousConsentMap) { + payload.append(FORM_FIELDS.consentMap, fields[FORM_FIELDS.consentMap]); + } + + if (fields[FORM_FIELDS.isActive] !== (savedState.enabled ? '1' : '0')) { + payload.append(FORM_FIELDS.isActive, fields[FORM_FIELDS.isActive]); + } + + return payload; +}; + +const deleteConsent = ({ card, consentId, deleteButton, onSuccess = () => { } }) => { + const formData = new FormData(); + formData.append(FORM_FIELDS.action, AJAX_ACTIONS.legalConsents); + formData.append(FORM_FIELDS.crudAction, CRUD_ACTIONS.delete); + formData.append(FORM_FIELDS.nonce, getNonceValue()); + formData.append(FORM_FIELDS.id, String(consentId)); + + deleteButton.classList.add(CSS_CLASSES.loading); + deleteButton.disabled = true; + + fetch(ajaxurl, { method: 'POST', body: formData }) + .then((response) => response.json()) + .then((data) => { + if (isSuccessfulResponse(data)) { + card.remove(); + onSuccess(); + showToast(__('Success', 'tutor'), getResponseMessage(data, __('Legal consent deleted successfully.', 'tutor')), 'success'); + return; + } + + showToast(__('Failed', 'tutor'), getResponseMessage(data, __('Failed to delete legal consent.', 'tutor')), 'error'); + }) + .catch(() => { + showToast(__('Failed', 'tutor'), __('Failed to delete legal consent.', 'tutor'), 'error'); + }) + .finally(() => { + deleteButton.classList.remove(CSS_CLASSES.loading); + deleteButton.disabled = false; + }); +}; + +const updateConsentEnabledState = ({ consentId, enabledInput, enabledHiddenInput, onSuccess }) => { + const formData = new FormData(); + formData.append(FORM_FIELDS.action, AJAX_ACTIONS.legalConsents); + formData.append(FORM_FIELDS.crudAction, CRUD_ACTIONS.update); + formData.append(FORM_FIELDS.nonce, getNonceValue()); + formData.append(FORM_FIELDS.id, String(consentId)); + formData.append(FORM_FIELDS.isActive, enabledInput.checked ? '1' : '0'); + + enabledInput.disabled = true; + + fetch(ajaxurl, { method: 'POST', body: formData }) + .then((response) => response.json()) + .then((data) => { + if (isSuccessfulResponse(data)) { + onSuccess(); + showToast(__('Success', 'tutor'), getResponseMessage(data, __('Legal consent updated successfully.', 'tutor')), 'success'); + return; + } + + enabledInput.checked = !enabledInput.checked; + enabledHiddenInput.value = enabledInput.checked ? 'on' : 'off'; + showToast(__('Failed', 'tutor'), getResponseMessage(data, __('Failed to update legal consent.', 'tutor')), 'error'); + }) + .catch(() => { + enabledInput.checked = !enabledInput.checked; + enabledHiddenInput.value = enabledInput.checked ? 'on' : 'off'; + showToast(__('Failed', 'tutor'), __('Failed to update legal consent.', 'tutor'), 'error'); + }) + .finally(() => { + enabledInput.disabled = false; + }); +}; + +const saveConsent = ({ card, consentId, saveButton, enabledInput, savedState, onSuccess }) => { + const currentState = getCardFormState(card); + + if (!isValidConsentState(currentState)) { + showToast(__('Error', 'tutor'), __('Please fill all the required fields.', 'tutor'), 'error'); + return; + } + + const formData = buildSavePayload({ card, consentId, enabledInput, savedState }); + + saveButton.classList.add(CSS_CLASSES.loading); + saveButton.disabled = true; + + fetch(ajaxurl, { method: 'POST', body: formData }) + .then((response) => response.json()) + .then((data) => { + if (isSuccessfulResponse(data)) { + saveButton.classList.remove(CSS_CLASSES.loading); + onSuccess(Number(data?.data?.id || 0)); + showToast(__('Success', 'tutor'), getResponseMessage(data, __('Legal consent saved successfully.', 'tutor')), 'success'); + return; + } + + const validationErrors = getValidationErrors(data); + const errorMessage = validationErrors || getResponseMessage(data, __('Failed to save legal consent.', 'tutor')); + showToast(__('Failed', 'tutor'), errorMessage, 'error'); + saveButton.classList.remove(CSS_CLASSES.loading); + saveButton.disabled = false; + }) + .catch(() => { + showToast(__('Failed', 'tutor'), __('Failed to save legal consent.', 'tutor'), 'error'); + saveButton.classList.remove(CSS_CLASSES.loading); + saveButton.disabled = false; + }); +}; + +const bindCard = (card) => { + const titleInput = card.querySelector(SELECTORS.consentTitleInput); + const enabledInput = card.querySelector(SELECTORS.consentEnabled); + const enabledHiddenInput = card.querySelector(SELECTORS.consentEnabledHidden); + const toggleButton = card.querySelector(SELECTORS.consentToggleButton); + const deleteButton = card.querySelector(SELECTORS.consentDeleteButton); + const cancelButton = card.querySelector(SELECTORS.consentCancelButton); + const saveButton = card.querySelector(SELECTORS.consentSaveButton); + const container = card.closest(SELECTORS.legalConsentsContainer); + + let consentId = Number(card.dataset[DATA_ATTRIBUTES.consentId] || 0); + let savedState = getCardFormState(card); + syncCardSaveButton(card, savedState); + syncCardDiscardButton(card, savedState); + + const onCardChange = () => { + markSettingsAsChanged(); + syncCardSaveButton(card, savedState); + syncCardDiscardButton(card, savedState); + }; + + titleInput?.addEventListener('input', (event) => { + updateConsentTitle(card, event.target.value); + onCardChange(); + }); + + if (enabledInput && enabledHiddenInput) { + enabledInput.addEventListener('change', () => { + enabledHiddenInput.value = enabledInput.checked ? 'on' : 'off'; + + if (!consentId) { + onCardChange(); + return; + } + + updateConsentEnabledState({ + consentId, + enabledInput, + enabledHiddenInput, + onSuccess: () => { + savedState = getCardFormState(card); + syncCardSaveButton(card, savedState); + }, + }); + }); + } + + card.querySelectorAll(SELECTORS.displayOnCheckboxes).forEach((checkbox) => { + checkbox.addEventListener('change', onCardChange); + }); + + card.querySelector(SELECTORS.consentMethodSelect)?.addEventListener('change', onCardChange); + + toggleButton?.addEventListener('click', () => { + if (toggleButton.disabled) { + return; + } + + toggleConsentCard(card, !card.classList.contains(CSS_CLASSES.collapsed)); + }); + + deleteButton?.addEventListener('click', () => { + const modal = document.getElementById('tutor-legal-consent-delete-modal'); + const confirmBtn = document.getElementById('tutor-legal-consent-confirm-delete'); + + if (!modal || !confirmBtn) { + if (!consentId) { + card.remove(); + showToast(__('Success', 'tutor'), __('Legal consent removed.', 'tutor'), 'success'); + syncEmptyState(container); + markSettingsAsChanged(); + return; + } + + deleteConsent({ + card, + consentId, + deleteButton, + onSuccess: () => syncEmptyState(container), + }); + return; + } + + modal.classList.add('tutor-is-active'); + + const newConfirmBtn = confirmBtn.cloneNode(true); + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); + + newConfirmBtn.addEventListener('click', () => { + const closeBtn = modal.querySelector('[data-tutor-modal-close]'); + + if (!consentId) { + closeBtn?.click(); + card.remove(); + showToast(__('Success', 'tutor'), __('Legal consent removed.', 'tutor'), 'success'); + syncEmptyState(container); + markSettingsAsChanged(); + return; + } + + deleteConsent({ + card, + consentId, + deleteButton: newConfirmBtn, + onSuccess: () => { + closeBtn?.click(); + syncEmptyState(container); + }, + }); + }); + }); + + cancelButton?.addEventListener('click', () => { + applyCardState(card, savedState); + syncCardSaveButton(card, savedState); + syncCardDiscardButton(card, savedState); + }); + + saveButton?.addEventListener('click', () => { + saveConsent({ + card, + consentId, + saveButton, + enabledInput, + savedState, + onSuccess: (returnedId) => { + if (!consentId && returnedId) { + consentId = returnedId; + card.dataset[DATA_ATTRIBUTES.consentId] = String(returnedId); + if (toggleButton) { + toggleButton.disabled = false; + } + if (enabledInput) { + enabledInput.disabled = false; + } + } + + toggleConsentCard(card, true); + savedState = getCardFormState(card); + syncCardSaveButton(card, savedState); + syncCardDiscardButton(card, savedState); + }, + }); + }); + + bindPageLinkControl(card, onCardChange); +}; + +const appendConsentCard = (container) => { + const template = container.querySelector(SELECTORS.consentTemplate); + const list = container.querySelector(SELECTORS.consentList); + + if (!template || !list) { + return; + } + + const nextIndex = Number(container.dataset[DATA_ATTRIBUTES.nextIndex] || list.children.length || 0); + const markup = template.innerHTML.replaceAll(TEMPLATE_INDEX_PLACEHOLDER, String(nextIndex)); + const wrapper = document.createElement('div'); + wrapper.innerHTML = markup.trim(); + + const card = wrapper.firstElementChild; + if (!card) { + return; + } + + list.appendChild(card); + container.dataset[DATA_ATTRIBUTES.nextIndex] = String(nextIndex + 1); + bindCard(card); + updateConsentTitle(card, card.querySelector(SELECTORS.consentTitleInput)?.value || ''); + syncEmptyState(container); + markSettingsAsChanged(); +}; + +const initLegalConsents = () => { + const page = getLegalConsentPage(); + if (!page) { + return; + } + + const container = page.querySelector(SELECTORS.legalConsentsContainer); + if (!container) { + return; + } + + const cards = container.querySelectorAll(SELECTORS.consentCard); + container.dataset[DATA_ATTRIBUTES.nextIndex] = String(cards.length); + + cards.forEach((card) => { + bindCard(card); + updateConsentTitle(card, card.querySelector(SELECTORS.consentTitleInput)?.value || ''); + }); + + syncEmptyState(container); + + container.querySelectorAll(SELECTORS.addConsent).forEach((button) => { + button.addEventListener('click', () => { + appendConsentCard(container); + }); + }); + + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + if (!target.closest(SELECTORS.pageDropdown) && !target.closest(SELECTORS.pageDropdownToggle)) { + closeAllPageDropdowns(container); + } + }); + + toggleHeaderSaveVisibility(); + syncFooterSaveButtons(); + + const headerSaveButton = getHeaderSaveButton(); + if (headerSaveButton) { + const observer = new MutationObserver(() => { + toggleHeaderSaveVisibility(); + syncFooterSaveButtons(); + }); + + observer.observe(headerSaveButton, { + attributes: true, + attributeFilter: ['disabled', 'class', 'style'], + }); + } + + document.querySelectorAll(SELECTORS.tabLinks).forEach((tab) => { + tab.addEventListener('click', () => { + window.setTimeout(() => { + toggleHeaderSaveVisibility(); + syncFooterSaveButtons(); + }, 0); + }); + }); + + const optionForm = document.getElementById(SELECTORS.tutorOptionForm); + optionForm?.addEventListener('input', syncFooterSaveButtons); + optionForm?.addEventListener('change', syncFooterSaveButtons); + window.addEventListener(TUTOR_OPTION_SAVED_EVENT, syncFooterSaveButtons); + + const tabPagesObserver = new MutationObserver(() => { + toggleHeaderSaveVisibility(); + syncFooterSaveButtons(); + }); + + document.querySelectorAll(SELECTORS.tabPages).forEach((tabPage) => { + tabPagesObserver.observe(tabPage, { + attributes: true, + attributeFilter: ['class'], + }); + }); +}; + +document.addEventListener('DOMContentLoaded', initLegalConsents); diff --git a/assets/react/admin-dashboard/segments/lib.js b/assets/src/js/admin-dashboard/segments/lib.js similarity index 100% rename from assets/react/admin-dashboard/segments/lib.js rename to assets/src/js/admin-dashboard/segments/lib.js diff --git a/assets/react/admin-dashboard/segments/manage-api-keys.js b/assets/src/js/admin-dashboard/segments/manage-api-keys.js similarity index 100% rename from assets/react/admin-dashboard/segments/manage-api-keys.js rename to assets/src/js/admin-dashboard/segments/manage-api-keys.js diff --git a/assets/react/admin-dashboard/segments/multiple_email_input.js b/assets/src/js/admin-dashboard/segments/multiple_email_input.js similarity index 100% rename from assets/react/admin-dashboard/segments/multiple_email_input.js rename to assets/src/js/admin-dashboard/segments/multiple_email_input.js diff --git a/assets/react/admin-dashboard/segments/navigation.js b/assets/src/js/admin-dashboard/segments/navigation.js similarity index 100% rename from assets/react/admin-dashboard/segments/navigation.js rename to assets/src/js/admin-dashboard/segments/navigation.js diff --git a/assets/react/admin-dashboard/segments/options.js b/assets/src/js/admin-dashboard/segments/options.js similarity index 100% rename from assets/react/admin-dashboard/segments/options.js rename to assets/src/js/admin-dashboard/segments/options.js diff --git a/assets/react/admin-dashboard/segments/popupToggle.js b/assets/src/js/admin-dashboard/segments/popupToggle.js similarity index 100% rename from assets/react/admin-dashboard/segments/popupToggle.js rename to assets/src/js/admin-dashboard/segments/popupToggle.js diff --git a/assets/react/admin-dashboard/segments/reset.js b/assets/src/js/admin-dashboard/segments/reset.js similarity index 81% rename from assets/react/admin-dashboard/segments/reset.js rename to assets/src/js/admin-dashboard/segments/reset.js index cdd8aab5ba..0ee942f629 100644 --- a/assets/react/admin-dashboard/segments/reset.js +++ b/assets/src/js/admin-dashboard/segments/reset.js @@ -47,7 +47,7 @@ const resetConfirmation = () => { if (xhttp.readyState === 4) { let pageData = JSON.parse(xhttp.response).data; pageData.forEach((item) => { - const field_types_associate = ['color_preset', 'upload_full', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number']; + const field_types_associate = ['color_field', 'upload_full', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'radio_horizontal_image', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number']; if (field_types_associate.includes(item.type)) { let itemName = 'tutor_option[' + item.key + ']'; @@ -60,36 +60,14 @@ const resetConfirmation = () => { elementOption.selected = typeof item.default === 'number' ? elementOption.value === item.default : item.default.includes(elementOption.value); }); - } else if (item.type == 'color_preset') { - - let presetItems = elementByName(itemName); - presetItems.forEach((presetItem) => { - let labelClasses = presetItem.parentElement.classList; - item.default.includes(presetItem.value) ? labelClasses.add('is-checked') : labelClasses.remove('is-checked'); - presetItem.checked = item.default.includes(presetItem.value) ? true : false; - }) - - item.fields.forEach((fields) => { - if (fields.key === item.default) { - fields.colors.forEach((picker) => { - let pickerName = 'tutor_option[' + picker.slug + ']'; - let pickerItem = elementByName(pickerName)[0]; - let pickerItemParent = pickerItem.parentElement; - pickerItem.value = picker.value; - pickerItem.nextElementSibling.innerText = picker.value; - - pickerItemParent.style.borderColor = picker.value; - pickerItemParent.style.boxShadow = `inset 0 0 0 1px ${picker.value}`; - - setTimeout(() => { - pickerItemParent.style.borderColor = '#cdcfd5'; - pickerItemParent.style.boxShadow = 'none'; - }, 5000); - }) - } - }) - - } else if (item.type == 'checkbox_horizontal' || item.type == 'checkbox_vertical' || item.type == 'radio_horizontal' || item.type == 'radio_horizontal_full' || item.type == 'radio_vertical' || item.type == 'group_radio' || item.type == 'group_radio_full_3') { + } else if (item.type == 'color_field') { + let pickerName = 'tutor_option[' + item.key + ']'; + let pickerItem = elementByName(pickerName)[0]; + if (pickerItem) { + pickerItem.value = item.default; + pickerItem.nextElementSibling.innerText = item.default; + } + } else if (item.type == 'checkbox_horizontal' || item.type == 'checkbox_vertical' || item.type == 'radio_horizontal' || item.type == 'radio_horizontal_full' || item.type == 'radio_horizontal_image' || item.type == 'radio_vertical' || item.type == 'group_radio' || item.type == 'group_radio_full_3') { if (item.type == 'checkbox_horizontal') { Object.keys(item.options).forEach((optionKeys) => { diff --git a/assets/src/js/admin-dashboard/segments/students.js b/assets/src/js/admin-dashboard/segments/students.js new file mode 100644 index 0000000000..5b01c1bb71 --- /dev/null +++ b/assets/src/js/admin-dashboard/segments/students.js @@ -0,0 +1,161 @@ +/** + * Student actions consent logs modal. + * + * @since 4.0.0 + */ +const AJAX_URL = window.ajaxurl || window._tutorobject?.ajaxurl || ''; +const AJAX_ACTION = 'tutor_user_consents'; +const NONCE_KEY = window._tutorobject?.nonce_key || '_tutor_nonce'; +const NONCE_VALUE = window._tutorobject?.[NONCE_KEY] || ''; +const overlay = document.getElementById('tutor-consent-logs-modal'); +const modalBody = overlay?.querySelector('.tutor-consent-logs-modal-body'); +const downloadBtn = overlay?.querySelector('[data-consent-logs-download]'); +const userNameEl = overlay?.querySelector('.tutor-consent-user-card-name'); +const userJoinedEl = overlay?.querySelector('.tutor-consent-user-card-joined'); +const userAvatarEl = overlay?.querySelector('.tutor-consent-user-card img'); +// Current open state. +let currentUserId = 0; +let currentUserName = ''; +let currentUserJoined = ''; +let currentUserEmail = ''; +let currentUserLogin = ''; +let currentLogs = []; +const { __ } = wp.i18n; +/** + * Build a human-readable title from the consent title. + * + * @param {Object} log + * @param {boolean} includeAccepted Whether to prefix with "Accepted" + * @returns {string} + */ +const getLogTitle = (log, includeAccepted = true) => { + if (log.consent_title) { + return includeAccepted ? `${__('Accepted', 'tutor')} ${log.consent_title}` : log.consent_title; + } + return includeAccepted ? __('Accepted Consent', 'tutor') : __('Consent', 'tutor'); +}; +const renderTimeline = (logs) => { + const items = logs.map((log, index) => { + const title = getLogTitle(log); + const date = log.created_at_gmt || ''; + const ago = log.timeAgo || log.time_ago || ''; + const ip = log.ip_address ? `IP: ${log.ip_address}` : ''; + const source = log.source ? `Source: ${log.source}` : ''; + const agent = log.user_agent ? `Agent: ${log.user_agent}` : ''; + const metaLines = [ip, source, agent].filter(Boolean); + return ` + + `; + }); + return items.join(''); +}; +const showLoading = () => { + if (!modalBody) return; + modalBody.innerHTML = `
    ${__('Loading…', 'tutor')}
    `; +}; +const showEmpty = () => { + if (!modalBody) return; + modalBody.innerHTML = `
    ${__('No consent logs found.', 'tutor')}
    `; +}; +const fetchAndRender = (userId, userName, userJoined, avatarSrc, userEmail, userLogin) => { + if (!overlay || !modalBody) return; + currentUserId = userId; + currentUserName = userName; + currentUserJoined = userJoined; + currentUserEmail = userEmail; + currentUserLogin = userLogin; + // Fill user card. + if (userNameEl) userNameEl.textContent = userName; + if (userJoinedEl) userJoinedEl.textContent = userJoined ? `${__('Joined', 'tutor')} ${userJoined}` : ''; + if (userAvatarEl && avatarSrc) userAvatarEl.src = avatarSrc; + // showLoading(); + const body = new FormData(); + body.append('action', AJAX_ACTION); + body.append('user_action', 'all_consents_given_by_user'); + body.append('user_id', userId); + body.append(NONCE_KEY, NONCE_VALUE); + fetch(AJAX_URL, { method: 'POST', body }) + .then((r) => r.json()) + .then((data) => { + const logs = data.data; + currentLogs = logs; + if (!logs.length) { + showEmpty(); + return; + } + const userCard = ` + + `; + modalBody.innerHTML = ` + + ${userCard} + `; + }) + .catch(() => showEmpty()); +}; +const downloadCSV = () => { + if (!currentLogs.length) return; + + const studentInfo = [ + ['Name:', currentUserName], + ['User Name:', currentUserLogin], + ['Email:', currentUserEmail], + ['Joined At:', currentUserJoined], + [], + ]; + + const headers = ['Title', 'Date (UTC)', 'IP Address', 'Source', 'User Agent']; + + const rows = currentLogs.map((log) => [ + getLogTitle(log, false), + log.created_at_gmt || '', + log.ip_address || '', + log.source || '', + log.user_agent || '', + ]); + + const escape = (v) => `"${String(v).replace(/"/g, '""')}"`; + + const csv = [...studentInfo, headers, ...rows] + .map((row) => row.map(escape).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `consent-logs-${currentUserName}.csv`; + a.click(); + URL.revokeObjectURL(url); +}; +const initConsentLogTriggers = () => { + document.querySelectorAll('[data-consent-logs-trigger]').forEach((btn) => { + btn.addEventListener('click', () => { + const userId = btn.dataset.userId || ''; + const userName = btn.dataset.userName || ''; + const userJoined = btn.dataset.userJoined || ''; + const avatarSrc = btn.dataset.avatarSrc || ''; + const userEmail = btn.dataset.userEmail || ''; + const userLogin = btn.dataset.userLogin || ''; + fetchAndRender(userId, userName, userJoined, avatarSrc, userEmail, userLogin); + }); + }); +}; +if (downloadBtn) downloadBtn.addEventListener('click', downloadCSV); +document.addEventListener('DOMContentLoaded', initConsentLogTriggers); \ No newline at end of file diff --git a/assets/react/admin-dashboard/segments/withdraw.js b/assets/src/js/admin-dashboard/segments/withdraw.js similarity index 100% rename from assets/react/admin-dashboard/segments/withdraw.js rename to assets/src/js/admin-dashboard/segments/withdraw.js diff --git a/assets/react/admin-dashboard/tutor-admin.js b/assets/src/js/admin-dashboard/tutor-admin.js similarity index 99% rename from assets/react/admin-dashboard/tutor-admin.js rename to assets/src/js/admin-dashboard/tutor-admin.js index ba94343cbd..ae35d8f4f9 100644 --- a/assets/react/admin-dashboard/tutor-admin.js +++ b/assets/src/js/admin-dashboard/tutor-admin.js @@ -1,11 +1,14 @@ -import '../front/_select_dd_search'; -import './quiz-attempts'; +import ajaxHandler from '../helper/ajax-handler'; + + import './segments/color-preset'; import './segments/column-filter'; +import './segments/consent-logs'; import './segments/editor_full'; import './segments/filter'; import './segments/image-preview'; import './segments/import-export'; +import './segments/legal-consents'; import './segments/lib'; import './segments/manage-api-keys'; import './segments/multiple_email_input'; @@ -13,8 +16,10 @@ import './segments/navigation'; import './segments/options'; import './segments/reset'; import './segments/withdraw'; + +import '../front/_select_dd_search'; +import './quiz-attempts'; import './wp-events-subscriber'; -import ajaxHandler from '../helper/ajax-handler'; document.querySelectorAll('.tutor-control-button').forEach(function (button) { button.addEventListener('click', function (event) { diff --git a/assets/react/admin-dashboard/tutor-setup.js b/assets/src/js/admin-dashboard/tutor-setup.js similarity index 100% rename from assets/react/admin-dashboard/tutor-setup.js rename to assets/src/js/admin-dashboard/tutor-setup.js diff --git a/assets/react/admin-dashboard/wp-events-subscriber.js b/assets/src/js/admin-dashboard/wp-events-subscriber.js similarity index 100% rename from assets/react/admin-dashboard/wp-events-subscriber.js rename to assets/src/js/admin-dashboard/wp-events-subscriber.js diff --git a/assets/react/front/_select_dd_search.js b/assets/src/js/front/_select_dd_search.js similarity index 99% rename from assets/react/front/_select_dd_search.js rename to assets/src/js/front/_select_dd_search.js index c78684cb55..aa577c8135 100644 --- a/assets/react/front/_select_dd_search.js +++ b/assets/src/js/front/_select_dd_search.js @@ -163,7 +163,7 @@ window.selectSearchField = (selectElement) => {