Compare commits
3
Commits
v0.3.2
...
1b2349e977
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2349e977 | ||
|
|
95db152b91 | ||
|
|
5194ed0a17 |
@@ -16,7 +16,11 @@ class HandleAppearance
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
$appearance = $request->cookie('appearance');
|
||||||
|
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||||
|
|
||||||
|
$request->attributes->set('appearance', $appearance);
|
||||||
|
View::share('appearance', $appearance);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
|
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||||
|
'localization' => [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'timeZone' => config('app.timezone'),
|
||||||
|
],
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
+87
-122
@@ -1,56 +1,98 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@import './theme.css';
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
|
@source not '../../bootstrap/ssr';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Inter;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Inter;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans:
|
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
|
||||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
|
||||||
'Noto Color Emoji';
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
--radius-sm: 0.125rem;
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: var(--radius);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.75rem;
|
||||||
|
|
||||||
|
--spacing-page: 1.5rem;
|
||||||
|
--spacing-column: 1rem;
|
||||||
|
--spacing-card: 0.75rem;
|
||||||
|
--spacing-stack: 0.5rem;
|
||||||
|
--spacing-control: 2.75rem;
|
||||||
|
|
||||||
|
--text-headline: 1.5rem;
|
||||||
|
--text-headline--line-height: 2rem;
|
||||||
|
--text-headline--font-weight: 600;
|
||||||
|
--text-headline--letter-spacing: -0.02em;
|
||||||
|
--text-headline-mobile: 1.25rem;
|
||||||
|
--text-headline-mobile--line-height: 1.75rem;
|
||||||
|
--text-headline-mobile--font-weight: 600;
|
||||||
|
--text-headline-mobile--letter-spacing: -0.02em;
|
||||||
|
--text-title: 1.125rem;
|
||||||
|
--text-title--line-height: 1.5rem;
|
||||||
|
--text-title--font-weight: 600;
|
||||||
|
--text-title--letter-spacing: -0.01em;
|
||||||
|
--text-body: 0.875rem;
|
||||||
|
--text-body--line-height: 1.25rem;
|
||||||
|
--text-caption: 0.8125rem;
|
||||||
|
--text-caption--line-height: 1.125rem;
|
||||||
|
--text-label: 0.6875rem;
|
||||||
|
--text-label--line-height: 1rem;
|
||||||
|
--text-label--font-weight: 700;
|
||||||
|
--text-label--letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
--color-brand: var(--brand);
|
||||||
|
--color-brand-foreground: var(--brand-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
--color-sidebar: var(--sidebar-background);
|
--color-sidebar: var(--sidebar-background);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
@@ -61,112 +103,35 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
body,
|
|
||||||
html {
|
|
||||||
--font-sans:
|
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
|
||||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
|
||||||
'Noto Color Emoji';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: hsl(0 0% 100%);
|
|
||||||
--foreground: hsl(0 0% 3.9%);
|
|
||||||
--card: hsl(0 0% 100%);
|
|
||||||
--card-foreground: hsl(0 0% 3.9%);
|
|
||||||
--popover: hsl(0 0% 100%);
|
|
||||||
--popover-foreground: hsl(0 0% 3.9%);
|
|
||||||
--primary: hsl(0 0% 9%);
|
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
|
||||||
--secondary: hsl(0 0% 92.1%);
|
|
||||||
--secondary-foreground: hsl(0 0% 9%);
|
|
||||||
--muted: hsl(0 0% 96.1%);
|
|
||||||
--muted-foreground: hsl(0 0% 45.1%);
|
|
||||||
--accent: hsl(0 0% 96.1%);
|
|
||||||
--accent-foreground: hsl(0 0% 9%);
|
|
||||||
--destructive: hsl(0 84.2% 60.2%);
|
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
|
||||||
--border: hsl(0 0% 92.8%);
|
|
||||||
--input: hsl(0 0% 89.8%);
|
|
||||||
--ring: hsl(0 0% 3.9%);
|
|
||||||
--chart-1: hsl(12 76% 61%);
|
|
||||||
--chart-2: hsl(173 58% 39%);
|
|
||||||
--chart-3: hsl(197 37% 24%);
|
|
||||||
--chart-4: hsl(43 74% 66%);
|
|
||||||
--chart-5: hsl(27 87% 67%);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--sidebar-background: hsl(0 0% 98%);
|
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
|
||||||
--sidebar-primary: hsl(0 0% 10%);
|
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
|
||||||
--sidebar-accent: hsl(0 0% 94%);
|
|
||||||
--sidebar-accent-foreground: hsl(0 0% 30%);
|
|
||||||
--sidebar-border: hsl(0 0% 91%);
|
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
--sidebar: hsl(0 0% 98%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: hsl(0 0% 3.9%);
|
|
||||||
--foreground: hsl(0 0% 98%);
|
|
||||||
--card: hsl(0 0% 3.9%);
|
|
||||||
--card-foreground: hsl(0 0% 98%);
|
|
||||||
--popover: hsl(0 0% 3.9%);
|
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
|
||||||
--primary: hsl(0 0% 98%);
|
|
||||||
--primary-foreground: hsl(0 0% 9%);
|
|
||||||
--secondary: hsl(0 0% 14.9%);
|
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
|
||||||
--muted: hsl(0 0% 16.08%);
|
|
||||||
--muted-foreground: hsl(0 0% 63.9%);
|
|
||||||
--accent: hsl(0 0% 14.9%);
|
|
||||||
--accent-foreground: hsl(0 0% 98%);
|
|
||||||
--destructive: hsl(0 84% 60%);
|
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
|
||||||
--border: hsl(0 0% 14.9%);
|
|
||||||
--input: hsl(0 0% 14.9%);
|
|
||||||
--ring: hsl(0 0% 83.1%);
|
|
||||||
--chart-1: hsl(220 70% 50%);
|
|
||||||
--chart-2: hsl(160 60% 45%);
|
|
||||||
--chart-3: hsl(30 80% 55%);
|
|
||||||
--chart-4: hsl(280 65% 60%);
|
|
||||||
--chart-5: hsl(340 75% 55%);
|
|
||||||
--sidebar-background: hsl(0 0% 7%);
|
|
||||||
--sidebar-foreground: hsl(0 0% 95.9%);
|
|
||||||
--sidebar-primary: hsl(360, 100%, 100%);
|
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
|
||||||
--sidebar-accent: hsl(0 0% 15.9%);
|
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
|
||||||
--sidebar-border: hsl(0 0% 15.9%);
|
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background font-sans text-body text-foreground antialiased;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--brand-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preserve completion events while removing visible nonessential motion. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--radius: 0.25rem;
|
||||||
|
--background: #f7f9fb;
|
||||||
|
--foreground: #191c1e;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #191c1e;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #191c1e;
|
||||||
|
--primary: #006e2f;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #d0e1fb;
|
||||||
|
--secondary-foreground: #38485d;
|
||||||
|
--muted: #f2f4f6;
|
||||||
|
--muted-foreground: #505f76;
|
||||||
|
--accent: #dcfce7;
|
||||||
|
--accent-foreground: #005321;
|
||||||
|
--destructive: #ba1a1a;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--input: #6d7b6c;
|
||||||
|
--ring: #006e2f;
|
||||||
|
--brand: #22c55e;
|
||||||
|
--brand-foreground: #002109;
|
||||||
|
--success: #dcfce7;
|
||||||
|
--success-foreground: #005321;
|
||||||
|
--warning: #fef3c7;
|
||||||
|
--warning-foreground: #78350f;
|
||||||
|
--info: #dbeafe;
|
||||||
|
--info-foreground: #1e40af;
|
||||||
|
--chart-1: #006e2f;
|
||||||
|
--chart-2: #505f76;
|
||||||
|
--chart-3: #9e4036;
|
||||||
|
--chart-4: #92400e;
|
||||||
|
--chart-5: #1d4ed8;
|
||||||
|
--sidebar-background: #f2f4f6;
|
||||||
|
--sidebar-foreground: #191c1e;
|
||||||
|
--sidebar-primary: #006e2f;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: #dcfce7;
|
||||||
|
--sidebar-accent-foreground: #005321;
|
||||||
|
--sidebar-border: #e2e8f0;
|
||||||
|
--sidebar-ring: #006e2f;
|
||||||
|
--sidebar: #f2f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: #0f172a;
|
||||||
|
--foreground: #f1f5f9;
|
||||||
|
--card: #1e293b;
|
||||||
|
--card-foreground: #f1f5f9;
|
||||||
|
--popover: #1e293b;
|
||||||
|
--popover-foreground: #f1f5f9;
|
||||||
|
--primary: #4ae176;
|
||||||
|
--primary-foreground: #002109;
|
||||||
|
--secondary: #334155;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #1e293b;
|
||||||
|
--muted-foreground: #b7c8e1;
|
||||||
|
--accent: #173b2b;
|
||||||
|
--accent-foreground: #6bff8f;
|
||||||
|
--destructive: #fca5a5;
|
||||||
|
--destructive-foreground: #450a0a;
|
||||||
|
--border: #334155;
|
||||||
|
--input: #8293aa;
|
||||||
|
--ring: #4ae176;
|
||||||
|
--brand: #22c55e;
|
||||||
|
--brand-foreground: #002109;
|
||||||
|
--success: #173b2b;
|
||||||
|
--success-foreground: #6bff8f;
|
||||||
|
--warning: #422006;
|
||||||
|
--warning-foreground: #fde68a;
|
||||||
|
--info: #172554;
|
||||||
|
--info-foreground: #bfdbfe;
|
||||||
|
--chart-1: #4ae176;
|
||||||
|
--chart-2: #b7c8e1;
|
||||||
|
--chart-3: #ffb4a9;
|
||||||
|
--chart-4: #fbbf24;
|
||||||
|
--chart-5: #60a5fa;
|
||||||
|
--sidebar-background: #111c30;
|
||||||
|
--sidebar-foreground: #f1f5f9;
|
||||||
|
--sidebar-primary: #4ae176;
|
||||||
|
--sidebar-primary-foreground: #002109;
|
||||||
|
--sidebar-accent: #173b2b;
|
||||||
|
--sidebar-accent-foreground: #6bff8f;
|
||||||
|
--sidebar-border: #334155;
|
||||||
|
--sidebar-ring: #4ae176;
|
||||||
|
--sidebar: #111c30;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
+5
-5
@@ -3,7 +3,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|||||||
import type { DefineComponent } from 'vue';
|
import type { DefineComponent } from 'vue';
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import '../css/app.css';
|
import '../css/app.css';
|
||||||
import { initializeTheme } from '@/composables/useAppearance';
|
import { createAppearance } from '@/composables/useAppearance';
|
||||||
|
import { createLocalization } from '@/lib/localization';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
@@ -17,12 +18,11 @@ createInertiaApp({
|
|||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
createApp({ render: () => h(App, props) })
|
createApp({ render: () => h(App, props) })
|
||||||
.use(plugin)
|
.use(plugin)
|
||||||
|
.use(createLocalization(props.initialPage.props.localization))
|
||||||
|
.use(createAppearance(props.initialPage.props.appearance))
|
||||||
.mount(el);
|
.mount(el);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: '#4B5563',
|
color: 'var(--primary)',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// This will set light / dark mode on page load...
|
|
||||||
initializeTheme();
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
import { AlertCircle } from 'lucide-vue-next';
|
import { AlertCircle } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: string[];
|
errors: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = defineProps<Props>();
|
||||||
title: 'Something went wrong.',
|
const { t } = useLocalization();
|
||||||
});
|
|
||||||
|
|
||||||
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
||||||
</script>
|
</script>
|
||||||
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
|||||||
<template>
|
<template>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle class="size-4" />
|
<AlertCircle class="size-4" />
|
||||||
<AlertTitle>{{ title }}</AlertTitle>
|
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul class="list-inside list-disc text-sm">
|
<ul class="list-inside list-disc text-sm">
|
||||||
<li v-for="(error, index) in uniqueErrors" :key="index">
|
<li v-for="(error, index) in uniqueErrors" :key="index">
|
||||||
|
|||||||
@@ -1,33 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
import { useAppearance } from '@/composables/useAppearance';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'light', Icon: Sun, label: 'Light' },
|
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
||||||
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
||||||
{ value: 'system', Icon: Monitor, label: 'System' },
|
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
||||||
] as const;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
role="group"
|
||||||
|
:aria-label="t('appearance.label')"
|
||||||
|
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="{ value, Icon, label } in tabs"
|
v-for="{ value, Icon, label } in tabs"
|
||||||
:key="value"
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="appearance === value"
|
||||||
@click="updateAppearance(value)"
|
@click="updateAppearance(value)"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||||
appearance === value
|
appearance === value
|
||||||
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
||||||
<span class="ml-1.5 text-sm">{{ label }}</span>
|
<span>{{ t(label) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { AvatarRootProps } from "reka-ui";
|
||||||
import { AvatarRoot } from "reka-ui"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import type { AvatarVariants } from ".";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { AvatarRoot, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { avatarVariants } from ".";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
class?: HTMLAttributes["class"]
|
defineProps<
|
||||||
}>()
|
AvatarRootProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
size?: AvatarVariants["size"];
|
||||||
|
shape?: AvatarVariants["shape"];
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ size: "default", shape: "circle" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarRoot
|
<AvatarRoot
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
:data-size="size"
|
||||||
>
|
:data-shape="shape"
|
||||||
<slot />
|
v-bind="forwardedProps"
|
||||||
</AvatarRoot>
|
:class="cn(avatarVariants({ size, shape }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AvatarFallbackProps } from "reka-ui"
|
import type { AvatarFallbackProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { AvatarFallback } from "reka-ui"
|
import { AvatarFallback } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
|
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
|
||||||
</AvatarFallback>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AvatarFallback>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getInitials } from "@/composables/useInitials";
|
import type { AvatarVariants } from "@/components/ui/avatar";
|
||||||
import {
|
import type { UserModel } from "@/types/models/user-model";
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@/components/ui/avatar/index";
|
|
||||||
import { usePage } from "@inertiajs/vue3";
|
import { usePage } from "@inertiajs/vue3";
|
||||||
import { computed, PropType } from "vue";
|
import { computed } from "vue";
|
||||||
import { UserModel } from "@/types/models/user-model";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { getInitials } from "@/composables/useInitials";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = withDefaults(
|
||||||
user: {
|
defineProps<{
|
||||||
type: Object as PropType<UserModel>,
|
user?: UserModel;
|
||||||
|
rounded?: boolean;
|
||||||
|
size?: AvatarVariants["size"];
|
||||||
|
label?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
rounded: true,
|
||||||
|
size: "default",
|
||||||
},
|
},
|
||||||
rounded: {
|
);
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const user = computed(() => props.user ?? page.props.auth.user);
|
||||||
|
const accessibleLabel = computed(() => props.label ?? user.value.name);
|
||||||
const user = computed(() => props.user ?? auth.value.user)
|
const initials = computed(() => getInitials(user.value.name));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user.avatar"
|
v-if="user.avatar"
|
||||||
:src="user.avatar"
|
:src="user.avatar"
|
||||||
:alt="user.name"
|
:alt="accessibleLabel"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback
|
<AvatarFallback :aria-label="accessibleLabel">
|
||||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
{{ initials }}
|
||||||
>
|
|
||||||
{{ getInitials(auth.user?.name) }}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
export { default as Avatar } from "./Avatar.vue"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
import { cva } from "class-variance-authority";
|
||||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
|
||||||
|
export { default as Avatar } from "./Avatar.vue";
|
||||||
|
export { default as AvatarFallback } from "./AvatarFallback.vue";
|
||||||
|
export { default as AvatarImage } from "./AvatarImage.vue";
|
||||||
|
export { default as UserAvatar } from "./UserAvatar.vue";
|
||||||
|
|
||||||
|
export const avatarVariants = cva(
|
||||||
|
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "size-6 text-label",
|
||||||
|
default: "size-8 text-caption",
|
||||||
|
lg: "size-10 text-body",
|
||||||
|
xl: "size-16 text-title",
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
circle: "rounded-full",
|
||||||
|
rounded: "rounded-lg",
|
||||||
|
square: "rounded-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
shape: "circle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export type AvatarVariants = VariantProps<typeof avatarVariants>;
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui"
|
import type { PrimitiveProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import type { BadgeVariants } from "."
|
import type { BadgeVariants } from ".";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Primitive } from "reka-ui"
|
import { Primitive } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { badgeVariants } from "."
|
import { badgeVariants } from ".";
|
||||||
|
|
||||||
const props = defineProps<PrimitiveProps & {
|
const props = defineProps<
|
||||||
variant?: BadgeVariants["variant"]
|
PrimitiveProps & {
|
||||||
class?: HTMLAttributes["class"]
|
variant?: BadgeVariants["variant"];
|
||||||
}>()
|
class?: HTMLAttributes["class"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "variant");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
:class="cn(badgeVariants({ variant }), props.class)"
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Badge } from "./Badge.vue"
|
export { default as Badge } from "./Badge.vue";
|
||||||
|
|
||||||
export const badgeVariants = cva(
|
export const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||||
outline:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
success:
|
||||||
|
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
|
||||||
|
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
|
||||||
|
|||||||
@@ -1,29 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui"
|
import type { PrimitiveProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import type { ButtonVariants } from "."
|
import type { ButtonVariants } from ".";
|
||||||
import { Primitive } from "reka-ui"
|
import { Primitive } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { computed } from "vue";
|
||||||
import { buttonVariants } from "."
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from ".";
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants["variant"]
|
variant?: ButtonVariants["variant"];
|
||||||
size?: ButtonVariants["size"]
|
size?: ButtonVariants["size"];
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: "button",
|
as: "button",
|
||||||
})
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDisabled = computed(() => props.disabled || props.loading);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
:as="as"
|
:data-loading="loading ? '' : undefined"
|
||||||
:as-child="asChild"
|
:as="as"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:as-child="asChild"
|
||||||
>
|
:disabled="isDisabled"
|
||||||
<slot />
|
:aria-disabled="isDisabled ? 'true' : undefined"
|
||||||
</Primitive>
|
:aria-busy="loading ? 'true' : undefined"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<Spinner
|
||||||
|
v-if="loading"
|
||||||
|
data-slot="button-spinner"
|
||||||
|
:label="loadingLabel"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Button } from "./Button.vue"
|
export { default as Button } from "./Button.vue";
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
},
|
||||||
},
|
size: {
|
||||||
size: {
|
default: "min-h-control px-4 py-2 has-[>svg]:px-3",
|
||||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
|
||||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
|
||||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
icon: "size-control",
|
||||||
"icon": "size-9",
|
"icon-sm": "size-9",
|
||||||
"icon-sm": "size-8",
|
"icon-lg": "size-12",
|
||||||
"icon-lg": "size-10",
|
},
|
||||||
},
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Check } from "lucide-vue-next"
|
import { Check } from "lucide-vue-next";
|
||||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
const props = withDefaults(
|
||||||
const emits = defineEmits<CheckboxRootEmits>()
|
defineProps<
|
||||||
|
CheckboxRootProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ invalid: false },
|
||||||
|
);
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "invalid");
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CheckboxRoot
|
<CheckboxRoot
|
||||||
v-slot="slotProps"
|
v-slot="slotProps"
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
v-bind="forwarded"
|
:data-invalid="invalid ? '' : undefined"
|
||||||
:class="
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
v-bind="forwarded"
|
||||||
props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<CheckboxIndicator
|
'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
|
||||||
data-slot="checkbox-indicator"
|
props.class,
|
||||||
class="grid place-content-center text-current transition-none"
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<slot v-bind="slotProps">
|
<CheckboxIndicator
|
||||||
<Check class="size-3.5" />
|
data-slot="checkbox-indicator"
|
||||||
</slot>
|
class="grid place-content-center text-current transition-none"
|
||||||
</CheckboxIndicator>
|
>
|
||||||
</CheckboxRoot>
|
<slot v-bind="slotProps">
|
||||||
|
<Check class="size-4" stroke-width="2.5" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { useVModel } from "@vueuse/core"
|
import { useVModel } from "@vueuse/core";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
defaultValue?: string | number
|
defaultValue?: string | number;
|
||||||
modelValue?: string | number | null
|
modelValue?: string | number | null;
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
invalid?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: "update:modelValue", payload: string | number): void
|
(e: "update:modelValue", payload: string | number): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const modelValue = useVModel(props, "modelValue", emits, {
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
passive: true,
|
passive: true,
|
||||||
defaultValue: props.defaultValue,
|
defaultValue: props.defaultValue,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:data-invalid="invalid ? '' : undefined"
|
||||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
:class="
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
cn(
|
||||||
props.class,
|
'border-input bg-card placeholder:text-muted-foreground selection:bg-brand selection:text-brand-foreground min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs transition-[border-color,box-shadow] outline-none file:mr-3 file:inline-flex file:min-h-8 file:border-0 file:bg-transparent file:text-body file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 md:text-body',
|
||||||
)"
|
'hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
|
||||||
>
|
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,51 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectPortal,
|
SelectPortal,
|
||||||
SelectViewport,
|
SelectViewport,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
{
|
{
|
||||||
position: "popper",
|
position: "popper",
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
const emits = defineEmits<SelectContentEmits>()
|
const emits = defineEmits<SelectContentEmits>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="cn(
|
:class="
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
cn(
|
||||||
position === 'popper'
|
'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-lg',
|
||||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
position === 'popper' &&
|
||||||
props.class,
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
)
|
props.class,
|
||||||
"
|
)
|
||||||
>
|
"
|
||||||
<SelectScrollUpButton />
|
>
|
||||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
<SelectScrollUpButton />
|
||||||
<slot />
|
<SelectViewport
|
||||||
</SelectViewport>
|
:class="
|
||||||
<SelectScrollDownButton />
|
cn(
|
||||||
</SelectContent>
|
'p-1',
|
||||||
</SelectPortal>
|
position === 'popper' &&
|
||||||
|
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectItemProps } from "reka-ui"
|
import type { SelectItemProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Check } from "lucide-vue-next"
|
import { Check } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectItemIndicator,
|
SelectItemIndicator,
|
||||||
SelectItemText,
|
SelectItemText,
|
||||||
useForwardProps,
|
useForwardProps,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<
|
||||||
|
SelectItemProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-sm py-2 pr-8 pl-2 text-body outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
<span
|
||||||
<SelectItemIndicator>
|
class="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
<slot name="indicator-icon">
|
>
|
||||||
<Check class="size-4" />
|
<SelectItemIndicator>
|
||||||
</slot>
|
<slot name="indicator-icon">
|
||||||
</SelectItemIndicator>
|
<Check class="size-4" />
|
||||||
</span>
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
<SelectItemText>
|
<SelectItemText>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectTriggerProps } from "reka-ui"
|
import type { SelectTriggerProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { ChevronDown } from "lucide-vue-next"
|
import { ChevronDown } from "lucide-vue-next";
|
||||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
defineProps<
|
||||||
{ size: "default" },
|
SelectTriggerProps & {
|
||||||
)
|
class?: HTMLAttributes["class"];
|
||||||
|
size?: "sm" | "default";
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ size: "default", invalid: false },
|
||||||
|
);
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
v-bind="forwardedProps"
|
:data-invalid="invalid ? '' : undefined"
|
||||||
:class="cn(
|
:aria-invalid="invalid ? 'true' : undefined"
|
||||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
v-bind="forwardedProps"
|
||||||
props.class,
|
:class="
|
||||||
)"
|
cn(
|
||||||
>
|
'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
|
||||||
<slot />
|
props.class,
|
||||||
<SelectIcon as-child>
|
)
|
||||||
<ChevronDown class="size-4 opacity-50" />
|
"
|
||||||
</SelectIcon>
|
>
|
||||||
</SelectTrigger>
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDown
|
||||||
|
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
|
||||||
|
/>
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { Loader2Icon } from "lucide-vue-next"
|
import { Loader2Icon } from "lucide-vue-next";
|
||||||
import { cn } from "@/lib/utils"
|
import { computed } from "vue";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Loader2Icon
|
<Loader2Icon
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
:aria-label="accessibleLabel"
|
||||||
:class="cn('size-4 animate-spin', props.class)"
|
:class="cn('size-4 animate-spin', props.class)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,124 +1,107 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue';
|
import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, inject, readonly, ref } from 'vue';
|
||||||
import type { Appearance, ResolvedAppearance } from '@/types';
|
import type { Appearance, ResolvedAppearance } from '@/types';
|
||||||
|
|
||||||
export type { Appearance, ResolvedAppearance };
|
export type { Appearance, ResolvedAppearance };
|
||||||
|
|
||||||
export type UseAppearanceReturn = {
|
export type UseAppearanceReturn = {
|
||||||
appearance: Ref<Appearance>;
|
appearance: Readonly<Ref<Appearance>>;
|
||||||
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
||||||
updateAppearance: (value: Appearance) => void;
|
updateAppearance: (value: Appearance) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function updateTheme(value: Appearance): void {
|
const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 'system') {
|
export function normalizeAppearance(value: unknown): Appearance {
|
||||||
const mediaQueryList = window.matchMedia(
|
return value === 'light' || value === 'dark' ? value : 'system';
|
||||||
'(prefers-color-scheme: dark)',
|
}
|
||||||
);
|
|
||||||
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
|
|
||||||
|
|
||||||
|
/** Each Vue app owns its state, including each server-rendered request. */
|
||||||
|
export function createAppearance(initialAppearance: Appearance = 'system') {
|
||||||
|
const appearance = ref<Appearance>(normalizeAppearance(initialAppearance));
|
||||||
|
const systemDark = ref(false);
|
||||||
|
const resolvedAppearance = computed<ResolvedAppearance>(() =>
|
||||||
|
appearance.value === 'system'
|
||||||
|
? systemDark.value
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
: appearance.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
function applyTheme(): void {
|
||||||
document.documentElement.classList.toggle(
|
document.documentElement.classList.toggle(
|
||||||
'dark',
|
'dark',
|
||||||
systemTheme === 'dark',
|
resolvedAppearance.value === 'dark',
|
||||||
);
|
);
|
||||||
} else {
|
document.documentElement.style.colorScheme = resolvedAppearance.value;
|
||||||
document.documentElement.classList.toggle('dark', value === 'dark');
|
document.documentElement.dataset.appearance = appearance.value;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCookie = (name: string, value: string, days = 365) => {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAge = days * 24 * 60 * 60;
|
function updateAppearance(value: Appearance): void {
|
||||||
|
appearance.value = normalizeAppearance(value);
|
||||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
if (typeof window === 'undefined') {
|
||||||
};
|
return;
|
||||||
|
|
||||||
const mediaQuery = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStoredAppearance = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return localStorage.getItem('appearance') as Appearance | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefersDark = (): boolean => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSystemThemeChange = () => {
|
|
||||||
const currentAppearance = getStoredAppearance();
|
|
||||||
|
|
||||||
updateTheme(currentAppearance || 'system');
|
|
||||||
};
|
|
||||||
|
|
||||||
export function initializeTheme(): void {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize theme from saved preference or default to system...
|
|
||||||
const savedAppearance = getStoredAppearance();
|
|
||||||
updateTheme(savedAppearance || 'system');
|
|
||||||
|
|
||||||
// Set up system theme change listener...
|
|
||||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appearance = ref<Appearance>('system');
|
|
||||||
|
|
||||||
export function useAppearance(): UseAppearanceReturn {
|
|
||||||
onMounted(() => {
|
|
||||||
const savedAppearance = localStorage.getItem(
|
|
||||||
'appearance',
|
|
||||||
) as Appearance | null;
|
|
||||||
|
|
||||||
if (savedAppearance) {
|
|
||||||
appearance.value = savedAppearance;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedAppearance = computed<ResolvedAppearance>(() => {
|
|
||||||
if (appearance.value === 'system') {
|
|
||||||
return prefersDark() ? 'dark' : 'light';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return appearance.value;
|
applyTheme();
|
||||||
});
|
try {
|
||||||
|
window.localStorage.setItem('appearance', appearance.value);
|
||||||
|
} catch {
|
||||||
|
// Private or embedded browsers may deny storage; the current theme still works.
|
||||||
|
}
|
||||||
|
document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAppearance(value: Appearance) {
|
function handleSystemChange(event: MediaQueryListEvent): void {
|
||||||
appearance.value = value;
|
systemDark.value = event.matches;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
// Store in localStorage for client-side persistence...
|
function handleStorageChange(event: StorageEvent): void {
|
||||||
localStorage.setItem('appearance', value);
|
if (event.key === 'appearance' || event.key === null) {
|
||||||
|
updateAppearance(normalizeAppearance(event.newValue));
|
||||||
// Store in cookie for SSR...
|
}
|
||||||
setCookie('appearance', value);
|
|
||||||
|
|
||||||
updateTheme(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appearance,
|
appearance: readonly(appearance),
|
||||||
resolvedAppearance,
|
resolvedAppearance,
|
||||||
updateAppearance,
|
updateAppearance,
|
||||||
|
install(app: App): void {
|
||||||
|
app.provide(appearanceKey, {
|
||||||
|
appearance: readonly(appearance),
|
||||||
|
resolvedAppearance,
|
||||||
|
updateAppearance,
|
||||||
|
});
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blocking head script already reconciled local storage and the SSR cookie.
|
||||||
|
appearance.value = normalizeAppearance(
|
||||||
|
document.documentElement.dataset.appearance ??
|
||||||
|
initialAppearance,
|
||||||
|
);
|
||||||
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
systemDark.value = media.matches;
|
||||||
|
applyTheme();
|
||||||
|
media.addEventListener('change', handleSystemChange);
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
|
||||||
|
app.onUnmount(() => {
|
||||||
|
media.removeEventListener('change', handleSystemChange);
|
||||||
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAppearance(): UseAppearanceReturn {
|
||||||
|
const appearance = inject(appearanceKey);
|
||||||
|
if (!appearance) {
|
||||||
|
throw new Error(
|
||||||
|
'The appearance plugin must be installed before useAppearance().',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return appearance;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { inject } from 'vue';
|
||||||
|
import { localizationKey } from '@/lib/localization';
|
||||||
|
|
||||||
|
export function useLocalization() {
|
||||||
|
const localization = inject(localizationKey);
|
||||||
|
if (!localization) {
|
||||||
|
throw new Error(
|
||||||
|
'The localization plugin must be installed before useLocalization().',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return localization;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { readonly, ref } from 'vue';
|
||||||
|
import type { App, InjectionKey } from 'vue';
|
||||||
|
import { en } from '@/locales/en';
|
||||||
|
import type {
|
||||||
|
LocalizationSettings,
|
||||||
|
TranslationCatalog,
|
||||||
|
TranslationKey,
|
||||||
|
TranslationParameters,
|
||||||
|
} from '@/types/localization';
|
||||||
|
|
||||||
|
const defaultLocale = 'en';
|
||||||
|
const catalogs: Record<string, TranslationCatalog> = { en };
|
||||||
|
|
||||||
|
function placeholders(message: string): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
[...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate every catalog, including messages that no component has rendered yet. */
|
||||||
|
export function validateCatalog(catalog: TranslationCatalog): void {
|
||||||
|
for (const key of Object.keys(en) as TranslationKey[]) {
|
||||||
|
const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined;
|
||||||
|
const source = en[key];
|
||||||
|
|
||||||
|
if (message === undefined || typeof message !== typeof source) {
|
||||||
|
throw new Error(`Missing or invalid translation: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = placeholders(
|
||||||
|
typeof source === 'string' ? source : source.other,
|
||||||
|
);
|
||||||
|
const forms =
|
||||||
|
typeof message === 'string'
|
||||||
|
? [message]
|
||||||
|
: Object.values(message ?? {});
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof message !== 'string' &&
|
||||||
|
(!message || typeof message.other !== 'string')
|
||||||
|
) {
|
||||||
|
throw new Error(`Missing plural fallback: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const form of forms) {
|
||||||
|
if (
|
||||||
|
typeof form !== 'string' ||
|
||||||
|
form.trim() === '' ||
|
||||||
|
JSON.stringify(placeholders(form)) !== JSON.stringify(expected)
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid translation placeholders: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalization(
|
||||||
|
settings: Partial<LocalizationSettings> = {},
|
||||||
|
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
|
||||||
|
) {
|
||||||
|
const messages: Record<string, TranslationCatalog> = {
|
||||||
|
...availableCatalogs,
|
||||||
|
en,
|
||||||
|
};
|
||||||
|
for (const catalog of Object.values(messages)) {
|
||||||
|
validateCatalog(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocale(requested: string): string {
|
||||||
|
try {
|
||||||
|
const [canonical] = Intl.getCanonicalLocales(
|
||||||
|
requested.replaceAll('_', '-'),
|
||||||
|
);
|
||||||
|
if (Object.hasOwn(messages, canonical)) {
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
const language = canonical.split('-')[0];
|
||||||
|
return Object.hasOwn(messages, language) ? language : defaultLocale;
|
||||||
|
} catch {
|
||||||
|
return defaultLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = ref(resolveLocale(settings.locale ?? defaultLocale));
|
||||||
|
const timeZone = settings.timeZone ?? 'UTC';
|
||||||
|
|
||||||
|
function number(
|
||||||
|
value: number,
|
||||||
|
options: Intl.NumberFormatOptions = {},
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(locale.value, options).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function date(
|
||||||
|
value: Date | number,
|
||||||
|
options: Intl.DateTimeFormatOptions = {},
|
||||||
|
): string {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
timeZone,
|
||||||
|
...options,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interpolate plain text only; render with Vue text bindings, never v-html. */
|
||||||
|
function t(
|
||||||
|
key: TranslationKey,
|
||||||
|
parameters: TranslationParameters = {},
|
||||||
|
): string {
|
||||||
|
const catalog = messages[locale.value];
|
||||||
|
if (!Object.hasOwn(catalog, key)) {
|
||||||
|
throw new Error(`Missing translation: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = catalog[key];
|
||||||
|
let template: string;
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
template = message;
|
||||||
|
} else {
|
||||||
|
const count = parameters.count;
|
||||||
|
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||||
|
throw new Error(`Translation requires a finite count: ${key}`);
|
||||||
|
}
|
||||||
|
template =
|
||||||
|
message[new Intl.PluralRules(locale.value).select(count)] ??
|
||||||
|
message.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
||||||
|
if (!Object.hasOwn(parameters, name)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing translation parameter: ${key}.${name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = parameters[name];
|
||||||
|
return typeof value === 'number' ? number(value) : value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const localization = {
|
||||||
|
locale: readonly(locale),
|
||||||
|
t,
|
||||||
|
number,
|
||||||
|
date,
|
||||||
|
setLocale(requested: string): void {
|
||||||
|
locale.value = resolveLocale(requested);
|
||||||
|
},
|
||||||
|
install(app: App): void {
|
||||||
|
app.provide(localizationKey, localization);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return localization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Localization = ReturnType<typeof createLocalization>;
|
||||||
|
export const localizationKey: InjectionKey<Localization> =
|
||||||
|
Symbol('localization');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const en = {
|
||||||
|
'appearance.label': 'Color theme',
|
||||||
|
'appearance.light': 'Light',
|
||||||
|
'appearance.dark': 'Dark',
|
||||||
|
'appearance.system': 'System',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.loading': 'Loading…',
|
||||||
|
'common.error': 'Something went wrong.',
|
||||||
|
'pagination.page': 'Page {page} of {total}',
|
||||||
|
'tickets.count': {
|
||||||
|
one: '{count} ticket',
|
||||||
|
other: '{count} tickets',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
+21
-1
@@ -4,6 +4,12 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|||||||
import type { DefineComponent } from 'vue';
|
import type { DefineComponent } from 'vue';
|
||||||
import { createSSRApp, h } from 'vue';
|
import { createSSRApp, h } from 'vue';
|
||||||
import { renderToString } from 'vue/server-renderer';
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createLocalization } from '@/lib/localization';
|
||||||
|
import type { LocalizationSettings } from '@/types/localization';
|
||||||
|
import {
|
||||||
|
createAppearance,
|
||||||
|
normalizeAppearance,
|
||||||
|
} from '@/composables/useAppearance';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
@@ -19,7 +25,21 @@ createServer(
|
|||||||
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
|
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
|
||||||
),
|
),
|
||||||
setup: ({ App, props, plugin }) =>
|
setup: ({ App, props, plugin }) =>
|
||||||
createSSRApp({ render: () => h(App, props) }).use(plugin),
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(
|
||||||
|
createAppearance(
|
||||||
|
normalizeAppearance(
|
||||||
|
props.initialPage.props.appearance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
createLocalization(
|
||||||
|
props.initialPage.props
|
||||||
|
.localization as LocalizationSettings,
|
||||||
|
),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{ cluster: true },
|
{ cluster: true },
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+4
@@ -1,4 +1,6 @@
|
|||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
|
import type { LocalizationSettings } from '@/types/localization';
|
||||||
|
import type { Appearance } from '@/types/ui';
|
||||||
import type { Page, Router } from '@inertiajs/core';
|
import type { Page, Router } from '@inertiajs/core';
|
||||||
import { createHeadManager } from '@inertiajs/vue3';
|
import { createHeadManager } from '@inertiajs/vue3';
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ declare module '@inertiajs/core' {
|
|||||||
export interface InertiaConfig {
|
export interface InertiaConfig {
|
||||||
sharedPageProps: {
|
sharedPageProps: {
|
||||||
name: string;
|
name: string;
|
||||||
|
localization: LocalizationSettings;
|
||||||
|
appearance: Appearance;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { en } from '@/locales/en';
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof en;
|
||||||
|
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>> & {
|
||||||
|
other: string;
|
||||||
|
};
|
||||||
|
export type TranslationCatalog = Record<TranslationKey, string | PluralMessage>;
|
||||||
|
export type TranslationParameters = Record<string, string | number>;
|
||||||
|
|
||||||
|
export type LocalizationSettings = {
|
||||||
|
locale: string;
|
||||||
|
timeZone: string;
|
||||||
|
};
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-appearance="{{ $appearance ?? 'system' }}" @class(['dark' => ($appearance ?? 'system') === 'dark'])>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
{{-- Resolve before first paint; the Vue plugin reads the same data attribute. --}}
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const appearance = '{{ $appearance ?? "system" }}';
|
const root = document.documentElement;
|
||||||
|
let appearance = root.dataset.appearance;
|
||||||
if (appearance === 'system') {
|
try {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const stored = window.localStorage.getItem('appearance');
|
||||||
|
if (stored !== null) {
|
||||||
if (prefersDark) {
|
appearance = stored;
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the server cookie when browser storage is unavailable.
|
||||||
}
|
}
|
||||||
|
appearance = ['light', 'dark', 'system'].includes(appearance) ? appearance : 'system';
|
||||||
|
const dark = appearance === 'dark' || (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
root.dataset.appearance = appearance;
|
||||||
|
root.classList.toggle('dark', dark);
|
||||||
|
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
|
{{-- The same tokens are bundled by Vite, so first paint cannot drift. --}}
|
||||||
<style>
|
<style>
|
||||||
html {
|
{!! file_get_contents(resource_path('css/theme.css')) !!}
|
||||||
background-color: oklch(1 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
background-color: oklch(0.145 0 0);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
@@ -36,8 +36,7 @@
|
|||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preload" href="{{ asset('fonts/inter/InterVariable.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
|
||||||
|
|
||||||
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
|
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
|
||||||
@inertiaHead
|
@inertiaHead
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AppearanceTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('appearanceCookies')]
|
||||||
|
public function test_initial_render_uses_a_validated_theme(?string $cookie, string $expected): void
|
||||||
|
{
|
||||||
|
if ($cookie !== null) {
|
||||||
|
$this->withUnencryptedCookie('appearance', $cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('data-appearance="'.$expected.'"', false)
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('appearance', $expected))
|
||||||
|
->assertDontSee('fonts.bunny.net', false)
|
||||||
|
->assertDontSee('Instrument Sans', false)
|
||||||
|
->assertSee('fonts/inter/InterVariable.woff2', false);
|
||||||
|
|
||||||
|
preg_match('/<html[^>]+>/', $response->getContent(), $matches);
|
||||||
|
$this->assertSame($expected === 'dark', str_contains($matches[0], 'class="dark"'));
|
||||||
|
$this->assertStringContainsString(file_get_contents(resource_path('css/theme.css')), $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{?string, string}> */
|
||||||
|
public static function appearanceCookies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'default' => [null, 'system'],
|
||||||
|
'light' => ['light', 'light'],
|
||||||
|
'dark' => ['dark', 'dark'],
|
||||||
|
'system' => ['system', 'system'],
|
||||||
|
'invalid' => ['invalid', 'system'],
|
||||||
|
'empty' => ['', 'system'],
|
||||||
|
'malformed' => ['</script><script>alert(1)</script>', 'system'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_theme_behaviour_and_contrast(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/appearance.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Appearance behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_inter_is_bundled_with_its_license(): void
|
||||||
|
{
|
||||||
|
foreach (['InterVariable.woff2', 'InterVariable-Italic.woff2'] as $font) {
|
||||||
|
$this->assertSame('wOF2', file_get_contents(public_path('fonts/inter/'.$font), false, null, 0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertStringContainsString('SIL OPEN FONT LICENSE', file_get_contents(public_path('fonts/inter/LICENSE.txt')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LocalizationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_frontend_localization_behaviour(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/localization.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Localization behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_initial_page_shares_the_application_locale_and_time_zone(): void
|
||||||
|
{
|
||||||
|
config(['app.timezone' => 'Europe/Berlin']);
|
||||||
|
|
||||||
|
$this->withHeader('Accept-Language', 'de-DE')->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('lang="en"', false)
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('localization.locale', 'en')
|
||||||
|
->where('localization.timeZone', 'Europe/Berlin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_server_validation_remains_english(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('en', config('app.locale'));
|
||||||
|
$this->assertSame('en', config('app.fallback_locale'));
|
||||||
|
|
||||||
|
$validator = Validator::make(['email' => 'invalid'], [
|
||||||
|
'title' => ['required'],
|
||||||
|
'email' => ['email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('The title field is required.', $validator->errors()->first('title'));
|
||||||
|
$this->assertSame('The email field must be a valid email address.', $validator->errors()->first('email'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UiComponentsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_foundational_ui_component_behaviour(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ui-components.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('UI component behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue';
|
||||||
|
import AlertError from '../../resources/js/components/AlertError.vue';
|
||||||
|
import { Button } from '../../resources/js/components/ui/button';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
const localization = createLocalization();
|
||||||
|
createApp({
|
||||||
|
render: () =>
|
||||||
|
h('main', { class: 'mx-auto grid max-w-xl gap-column p-page' }, [
|
||||||
|
h('h1', { class: 'text-headline' }, 'Emerald design system'),
|
||||||
|
h(AppearanceTabs),
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{
|
||||||
|
class: 'grid gap-column rounded-md border bg-card p-card text-card-foreground',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('h2', { class: 'text-title' }, 'Readable surfaces'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'Inter, emerald actions and a visible keyboard focus.',
|
||||||
|
),
|
||||||
|
h('label', { for: 'sample-input' }, 'Sample input'),
|
||||||
|
h('input', {
|
||||||
|
id: 'sample-input',
|
||||||
|
class: 'min-h-control rounded-md border border-input bg-background px-3',
|
||||||
|
placeholder: 'Type here',
|
||||||
|
}),
|
||||||
|
h('div', { class: 'flex flex-wrap gap-stack' }, [
|
||||||
|
h(Button, {}, () => localization.t('common.save')),
|
||||||
|
h(Button, { variant: 'secondary' }, () =>
|
||||||
|
localization.t('common.cancel'),
|
||||||
|
),
|
||||||
|
h(Button, { variant: 'destructive' }, () => 'Delete'),
|
||||||
|
]),
|
||||||
|
h(AlertError, { errors: ['A readable validation error.'] }),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.use(localization)
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount('#app');
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-appearance="system">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Emerald appearance fixture</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./appearance-preview.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
function browserState(cookie, stored, dark, storageDenied = false) {
|
||||||
|
const root = { dataset: { appearance: cookie }, style: {}, dark: false };
|
||||||
|
root.classList = {
|
||||||
|
toggle: (_, value) => {
|
||||||
|
root.dark = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const listeners = new Map();
|
||||||
|
const storage = new Map(stored === null ? [] : [['appearance', stored]]);
|
||||||
|
const media = {
|
||||||
|
matches: dark,
|
||||||
|
addEventListener: (_, listener) => listeners.set('media', listener),
|
||||||
|
removeEventListener: () => listeners.delete('media'),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
document: { documentElement: root, cookie: '' },
|
||||||
|
window: {
|
||||||
|
matchMedia: () => media,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key) => {
|
||||||
|
if (storageDenied) {
|
||||||
|
throw new Error('Storage denied');
|
||||||
|
}
|
||||||
|
return storage.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
if (storageDenied) {
|
||||||
|
throw new Error('Storage denied');
|
||||||
|
}
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: (_, listener) =>
|
||||||
|
listeners.set('storage', listener),
|
||||||
|
removeEventListener: () => listeners.delete('storage'),
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
media,
|
||||||
|
listeners,
|
||||||
|
storage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAppearance, normalizeAppearance } =
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { default: AppearanceTabs } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/AppearanceTabs.vue',
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTheme = (value) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(AppearanceTabs) })
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance(value)),
|
||||||
|
);
|
||||||
|
const [light, dark] = await Promise.all([
|
||||||
|
renderTheme('light'),
|
||||||
|
renderTheme('dark'),
|
||||||
|
]);
|
||||||
|
const selectedButton = (html) =>
|
||||||
|
html.match(
|
||||||
|
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
||||||
|
)[1];
|
||||||
|
assert.match(selectedButton(light), />Light</);
|
||||||
|
assert.match(selectedButton(dark), />Dark</);
|
||||||
|
assert.match(light, /role="group" aria-label="Color theme"/);
|
||||||
|
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
||||||
|
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
||||||
|
assert.equal(normalizeAppearance('unexpected'), 'system');
|
||||||
|
|
||||||
|
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
||||||
|
const bootstrap = blade.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||||
|
for (const [cookie, stored, osDark, denied, expected, preference] of [
|
||||||
|
['system', null, false, false, false, 'system'],
|
||||||
|
['system', null, true, false, true, 'system'],
|
||||||
|
['dark', null, false, false, true, 'dark'],
|
||||||
|
['light', null, true, false, false, 'light'],
|
||||||
|
['light', 'dark', false, false, true, 'dark'],
|
||||||
|
['dark', null, false, true, true, 'dark'],
|
||||||
|
['light', 'invalid', true, false, true, 'system'],
|
||||||
|
]) {
|
||||||
|
const state = browserState(cookie, stored, osDark, denied);
|
||||||
|
vm.runInNewContext(bootstrap, state);
|
||||||
|
assert.equal(state.root.dark, expected);
|
||||||
|
assert.equal(state.root.dataset.appearance, preference);
|
||||||
|
assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const denied of [false, true]) {
|
||||||
|
const state = browserState('system', null, false, denied);
|
||||||
|
globalThis.window = state.window;
|
||||||
|
globalThis.document = state.document;
|
||||||
|
const theme = createAppearance();
|
||||||
|
let cleanup;
|
||||||
|
theme.install({
|
||||||
|
provide() {},
|
||||||
|
onUnmount(callback) {
|
||||||
|
cleanup = callback;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(theme.resolvedAppearance.value, 'light');
|
||||||
|
state.listeners.get('media')({ matches: true });
|
||||||
|
assert.equal(theme.resolvedAppearance.value, 'dark');
|
||||||
|
assert.equal(state.root.dark, true);
|
||||||
|
theme.updateAppearance('light');
|
||||||
|
assert.equal(state.root.dark, false);
|
||||||
|
assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/);
|
||||||
|
state.listeners.get('media')({ matches: false });
|
||||||
|
state.listeners.get('media')({ matches: true });
|
||||||
|
assert.equal(theme.resolvedAppearance.value, 'light');
|
||||||
|
theme.updateAppearance('system');
|
||||||
|
assert.equal(theme.resolvedAppearance.value, 'dark');
|
||||||
|
state.listeners.get('storage')({
|
||||||
|
key: 'appearance',
|
||||||
|
newValue: 'light',
|
||||||
|
});
|
||||||
|
assert.equal(theme.appearance.value, 'light');
|
||||||
|
state.listeners.get('storage')({ key: null, newValue: null });
|
||||||
|
assert.equal(theme.appearance.value, 'system');
|
||||||
|
if (!denied) {
|
||||||
|
assert.equal(state.storage.get('appearance'), 'system');
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
assert.equal(state.listeners.size, 0);
|
||||||
|
delete globalThis.window;
|
||||||
|
delete globalThis.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await readFile('resources/css/theme.css', 'utf8');
|
||||||
|
function luminance(hex) {
|
||||||
|
const values = hex
|
||||||
|
.match(/[a-f\d]{2}/gi)
|
||||||
|
.map((part) => parseInt(part, 16) / 255)
|
||||||
|
.map((value) =>
|
||||||
|
value <= 0.04045
|
||||||
|
? value / 12.92
|
||||||
|
: ((value + 0.055) / 1.055) ** 2.4,
|
||||||
|
);
|
||||||
|
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
|
||||||
|
}
|
||||||
|
function contrast(first, second) {
|
||||||
|
const values = [luminance(first), luminance(second)].sort(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
return (values[0] + 0.05) / (values[1] + 0.05);
|
||||||
|
}
|
||||||
|
for (const selector of [':root', '.dark']) {
|
||||||
|
const declarations = css
|
||||||
|
.slice(css.indexOf(selector))
|
||||||
|
.match(/\{([^}]+)\}/)[1];
|
||||||
|
const tokens = Object.fromEntries(
|
||||||
|
[...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map(
|
||||||
|
(match) => [match[1], match[2]],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [background, foreground] of [
|
||||||
|
['background', 'foreground'],
|
||||||
|
['card', 'card-foreground'],
|
||||||
|
['popover', 'popover-foreground'],
|
||||||
|
['primary', 'primary-foreground'],
|
||||||
|
['secondary', 'secondary-foreground'],
|
||||||
|
['muted', 'muted-foreground'],
|
||||||
|
['accent', 'accent-foreground'],
|
||||||
|
['destructive', 'destructive-foreground'],
|
||||||
|
['card', 'destructive'],
|
||||||
|
['brand', 'brand-foreground'],
|
||||||
|
['success', 'success-foreground'],
|
||||||
|
['warning', 'warning-foreground'],
|
||||||
|
['info', 'info-foreground'],
|
||||||
|
['sidebar-background', 'sidebar-foreground'],
|
||||||
|
['sidebar-accent', 'sidebar-accent-foreground'],
|
||||||
|
['sidebar-primary', 'sidebar-primary-foreground'],
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
contrast(tokens[background], tokens[foreground]) >= 4.5,
|
||||||
|
selector + ': ' + background + '/' + foreground,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(contrast(tokens.background, tokens.ring) >= 3);
|
||||||
|
assert.ok(contrast(tokens.card, tokens.input) >= 3);
|
||||||
|
}
|
||||||
|
const appCss = await readFile('resources/css/app.css', 'utf8');
|
||||||
|
assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/);
|
||||||
|
assert.match(appCss, /animation-duration: 0.01ms !important/);
|
||||||
|
assert.match(appCss, /transition-duration: 0.01ms !important/);
|
||||||
|
assert.match(appCss, /scroll-behavior: auto !important/);
|
||||||
|
console.log('Appearance behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
delete globalThis.window;
|
||||||
|
delete globalThis.document;
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { computed, createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createLocalization, validateCatalog } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { useLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useLocalization.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
|
||||||
|
const { default: AlertError } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/AlertError.vue',
|
||||||
|
);
|
||||||
|
|
||||||
|
const english = createLocalization();
|
||||||
|
assert.equal(english.t('common.save'), 'Save');
|
||||||
|
assert.equal(
|
||||||
|
english.t('pagination.page', { page: 0, total: 2000 }),
|
||||||
|
'Page 0 of 2,000',
|
||||||
|
);
|
||||||
|
for (const [count, expected] of [
|
||||||
|
[0, '0 tickets'],
|
||||||
|
[1, '1 ticket'],
|
||||||
|
[2, '2 tickets'],
|
||||||
|
[1.5, '1.5 tickets'],
|
||||||
|
]) {
|
||||||
|
assert.equal(english.t('tickets.count', { count }), expected);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
english.number(1234.5, { minimumFractionDigits: 2 }),
|
||||||
|
'1,234.50',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
english.number(12, { style: 'currency', currency: 'USD' }),
|
||||||
|
'$12.00',
|
||||||
|
);
|
||||||
|
assert.equal(english.date(new Date('2026-09-09T23:30:00Z')), '9/9/2026');
|
||||||
|
assert.equal(
|
||||||
|
createLocalization({ timeZone: 'Europe/Berlin' }).date(
|
||||||
|
new Date('2026-09-09T23:30:00Z'),
|
||||||
|
),
|
||||||
|
'9/10/2026',
|
||||||
|
);
|
||||||
|
assert.throws(() => english.t('missing.key'), /Missing translation/);
|
||||||
|
assert.throws(() => english.t('toString'), /Missing translation/);
|
||||||
|
assert.throws(
|
||||||
|
() => english.t('pagination.page', { page: 1 }),
|
||||||
|
/Missing translation parameter/,
|
||||||
|
);
|
||||||
|
for (const count of [undefined, '1', Infinity, NaN]) {
|
||||||
|
assert.throws(
|
||||||
|
() => english.t('tickets.count', { count }),
|
||||||
|
/finite count/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomplete = { ...en };
|
||||||
|
delete incomplete['common.save'];
|
||||||
|
assert.throws(
|
||||||
|
() => validateCatalog(incomplete),
|
||||||
|
/Missing or invalid translation/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCatalog({
|
||||||
|
...en,
|
||||||
|
'tickets.count': { one: '{count} ticket' },
|
||||||
|
}),
|
||||||
|
/plural fallback/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateCatalog({ ...en, 'pagination.page': 'Page {unknown}' }),
|
||||||
|
/placeholders/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateCatalog({ ...en, 'common.save': '' }),
|
||||||
|
/placeholders/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createLocalization({}, { de: incomplete }),
|
||||||
|
/Missing or invalid translation/,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const locale of ['de-DE', 'en_US', 'not a locale', '']) {
|
||||||
|
assert.equal(createLocalization({ locale }).locale.value, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
const de = {
|
||||||
|
...en,
|
||||||
|
'common.error': 'Etwas ist schiefgelaufen.',
|
||||||
|
'common.save': 'Speichern',
|
||||||
|
};
|
||||||
|
const translated = createLocalization({ locale: 'de_DE' }, { de });
|
||||||
|
assert.equal(translated.locale.value, 'de');
|
||||||
|
assert.equal(translated.number(1234.5), '1.234,5');
|
||||||
|
const label = computed(() => translated.t('common.save'));
|
||||||
|
assert.equal(label.value, 'Speichern');
|
||||||
|
translated.setLocale('en');
|
||||||
|
assert.equal(label.value, 'Save');
|
||||||
|
translated.setLocale('de');
|
||||||
|
assert.equal(label.value, 'Speichern');
|
||||||
|
assert.equal(english.locale.value, 'en');
|
||||||
|
|
||||||
|
const renderAlert = (localization, props = {}) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h(AlertError, {
|
||||||
|
errors: ['Invalid input', 'Invalid input'],
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
}).use(localization),
|
||||||
|
);
|
||||||
|
const [englishHtml, germanHtml] = await Promise.all([
|
||||||
|
renderAlert(english),
|
||||||
|
renderAlert(translated),
|
||||||
|
]);
|
||||||
|
assert.match(englishHtml, /Something went wrong\./);
|
||||||
|
assert.match(germanHtml, /Etwas ist schiefgelaufen\./);
|
||||||
|
assert.equal((englishHtml.match(/Invalid input/g) ?? []).length, 1);
|
||||||
|
assert.match(
|
||||||
|
await renderAlert(translated, { title: 'Custom title' }),
|
||||||
|
/Custom title/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await renderAlert(translated, { title: '' }),
|
||||||
|
/Etwas ist schiefgelaufen/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const InterpolatedText = {
|
||||||
|
setup() {
|
||||||
|
const { t } = useLocalization();
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
t('pagination.page', {
|
||||||
|
page: '<script>alert(1)</script>',
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const escaped = await renderToString(
|
||||||
|
createSSRApp(InterpolatedText).use(createLocalization()),
|
||||||
|
);
|
||||||
|
assert.match(escaped, /<script>/);
|
||||||
|
assert.doesNotMatch(escaped, /<script>/);
|
||||||
|
|
||||||
|
console.log('Localization behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { createApp, h, ref } from 'vue';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
} from '../../resources/js/components/ui/avatar';
|
||||||
|
import { Badge } from '../../resources/js/components/ui/badge';
|
||||||
|
import { Button } from '../../resources/js/components/ui/button';
|
||||||
|
import { Checkbox } from '../../resources/js/components/ui/checkbox';
|
||||||
|
import { Input } from '../../resources/js/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../../resources/js/components/ui/select';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
const Demo = {
|
||||||
|
setup() {
|
||||||
|
const selectedStatus = ref('review');
|
||||||
|
const subscribed = ref(true);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('main', { class: 'mx-auto grid max-w-4xl gap-column p-page' }, [
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex flex-wrap items-center justify-between gap-stack',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', {}, [
|
||||||
|
h(
|
||||||
|
'h1',
|
||||||
|
{ class: 'text-headline' },
|
||||||
|
'Foundational UI components',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'Fluid Emerald states and controls',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
h(AppearanceTabs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h('section', { class: 'grid gap-column sm:grid-cols-2' }, [
|
||||||
|
h(
|
||||||
|
'article',
|
||||||
|
{
|
||||||
|
class: 'grid content-start gap-column rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'h2',
|
||||||
|
{ class: 'text-title' },
|
||||||
|
'Buttons and badges',
|
||||||
|
),
|
||||||
|
h('div', { class: 'flex flex-wrap gap-stack' }, [
|
||||||
|
h(Button, {}, () => 'Primary'),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'secondary' },
|
||||||
|
() => 'Secondary',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'outline' },
|
||||||
|
() => 'Outline',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{ variant: 'destructive' },
|
||||||
|
() => 'Delete',
|
||||||
|
),
|
||||||
|
h(Button, { disabled: true }, () => 'Disabled'),
|
||||||
|
h(Button, { loading: true }, () => 'Saving'),
|
||||||
|
]),
|
||||||
|
h('div', { class: 'flex flex-wrap gap-stack' }, [
|
||||||
|
h(Badge, {}, () => 'Active'),
|
||||||
|
h(Badge, { variant: 'success' }, () => 'Done'),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'warning' },
|
||||||
|
() => 'Medium',
|
||||||
|
),
|
||||||
|
h(Badge, { variant: 'info' }, () => 'Low'),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'destructive' },
|
||||||
|
() => 'High',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'outline' },
|
||||||
|
() => 'KAN-18',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'article',
|
||||||
|
{
|
||||||
|
class: 'grid content-start gap-column rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'h2',
|
||||||
|
{ class: 'text-title' },
|
||||||
|
'Inputs and selection',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'grid gap-stack', for: 'title' },
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Title',
|
||||||
|
),
|
||||||
|
h(Input, {
|
||||||
|
id: 'title',
|
||||||
|
placeholder: 'Ticket title',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{
|
||||||
|
class: 'grid gap-stack',
|
||||||
|
for: 'invalid-title',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Invalid title',
|
||||||
|
),
|
||||||
|
h(Input, {
|
||||||
|
id: 'invalid-title',
|
||||||
|
invalid: true,
|
||||||
|
modelValue: 'Needs attention',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'grid gap-stack', for: 'status' },
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-body font-medium' },
|
||||||
|
'Status',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
Select,
|
||||||
|
{
|
||||||
|
modelValue: selectedStatus.value,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
selectedStatus.value = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(
|
||||||
|
SelectTrigger,
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(SelectValue, {
|
||||||
|
placeholder:
|
||||||
|
'Choose a status',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
h(SelectContent, {}, () => [
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'todo' },
|
||||||
|
() => 'To do',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'review' },
|
||||||
|
() => 'Review',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
SelectItem,
|
||||||
|
{ value: 'done' },
|
||||||
|
() => 'Done',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'flex items-center gap-stack' },
|
||||||
|
[
|
||||||
|
h(Checkbox, {
|
||||||
|
modelValue: subscribed.value,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
subscribed.value = Boolean(value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
h('span', {}, 'Notify me about changes'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{
|
||||||
|
class: 'flex items-center gap-stack rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(Avatar, { size: 'sm' }, () =>
|
||||||
|
h(AvatarFallback, {}, () => 'NS'),
|
||||||
|
),
|
||||||
|
h(Avatar, {}, () => h(AvatarFallback, {}, () => 'AK')),
|
||||||
|
h(Avatar, { size: 'lg' }, () =>
|
||||||
|
h(AvatarFallback, {}, () => 'LT'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'24px, 32px and 40px avatars',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
createApp(Demo)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance('light'))
|
||||||
|
.mount('#app');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-appearance="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>UI component preview</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/tests/Fixtures/ui-components-preview.mjs"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
|
||||||
|
const { default: Button } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/button/Button.vue',
|
||||||
|
);
|
||||||
|
const { buttonVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/button/index.ts',
|
||||||
|
);
|
||||||
|
const { default: Input } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/input/Input.vue',
|
||||||
|
);
|
||||||
|
const { default: Select } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/Select.vue',
|
||||||
|
);
|
||||||
|
const { default: SelectTrigger } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/SelectTrigger.vue',
|
||||||
|
);
|
||||||
|
const { default: SelectValue } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/select/SelectValue.vue',
|
||||||
|
);
|
||||||
|
const { default: Checkbox } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/checkbox/Checkbox.vue',
|
||||||
|
);
|
||||||
|
const { default: Badge } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/badge/Badge.vue',
|
||||||
|
);
|
||||||
|
const { badgeVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/badge/index.ts',
|
||||||
|
);
|
||||||
|
const { default: Avatar } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/Avatar.vue',
|
||||||
|
);
|
||||||
|
const { default: AvatarFallback } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/AvatarFallback.vue',
|
||||||
|
);
|
||||||
|
const { default: UserAvatar } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/UserAvatar.vue',
|
||||||
|
);
|
||||||
|
const { avatarVariants } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/ui/avatar/index.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
const german = createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{ de: { ...en, 'common.loading': 'Wird geladen…' } },
|
||||||
|
);
|
||||||
|
const render = (component, props = {}, slots = {}) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(component, props, slots) }).use(
|
||||||
|
german,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingButton = await render(
|
||||||
|
Button,
|
||||||
|
{ loading: true },
|
||||||
|
{ default: () => 'Save' },
|
||||||
|
);
|
||||||
|
assert.match(loadingButton, /<button/);
|
||||||
|
assert.match(loadingButton, /disabled/);
|
||||||
|
assert.match(loadingButton, /aria-disabled="true"/);
|
||||||
|
assert.match(loadingButton, /aria-busy="true"/);
|
||||||
|
assert.match(loadingButton, /data-loading/);
|
||||||
|
assert.match(loadingButton, /role="status"/);
|
||||||
|
assert.match(loadingButton, /aria-label="Wird geladen…"/);
|
||||||
|
assert.match(loadingButton, />Save</);
|
||||||
|
assert.match(buttonVariants(), /min-h-control/);
|
||||||
|
assert.match(buttonVariants(), /bg-brand/);
|
||||||
|
assert.match(buttonVariants({ variant: 'outline' }), /bg-card/);
|
||||||
|
|
||||||
|
const invalidInput = await render(Input, {
|
||||||
|
disabled: true,
|
||||||
|
invalid: true,
|
||||||
|
name: 'title',
|
||||||
|
});
|
||||||
|
assert.match(invalidInput, /<input/);
|
||||||
|
assert.match(invalidInput, /name="title"/);
|
||||||
|
assert.match(invalidInput, /disabled/);
|
||||||
|
assert.match(invalidInput, /aria-invalid="true"/);
|
||||||
|
assert.match(invalidInput, /data-invalid/);
|
||||||
|
assert.match(invalidInput, /min-h-control/);
|
||||||
|
|
||||||
|
const select = await render(
|
||||||
|
Select,
|
||||||
|
{ disabled: true },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
SelectTrigger,
|
||||||
|
{ invalid: true },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(SelectValue, { placeholder: 'Choose' }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.match(select, /role="combobox"/);
|
||||||
|
assert.match(select, /disabled/);
|
||||||
|
assert.match(select, /aria-invalid="true"/);
|
||||||
|
assert.match(select, /data-invalid/);
|
||||||
|
assert.match(select, /min-h-control/);
|
||||||
|
|
||||||
|
const checkbox = await render(Checkbox, {
|
||||||
|
disabled: true,
|
||||||
|
invalid: true,
|
||||||
|
});
|
||||||
|
assert.match(checkbox, /role="checkbox"/);
|
||||||
|
assert.match(checkbox, /aria-checked="false"/);
|
||||||
|
assert.match(checkbox, /disabled/);
|
||||||
|
assert.match(checkbox, /aria-invalid="true"/);
|
||||||
|
assert.match(checkbox, /data-invalid/);
|
||||||
|
|
||||||
|
const successBadge = await render(
|
||||||
|
Badge,
|
||||||
|
{ variant: 'success' },
|
||||||
|
{ default: () => 'Done' },
|
||||||
|
);
|
||||||
|
assert.match(successBadge, /bg-success/);
|
||||||
|
assert.match(successBadge, />Done</);
|
||||||
|
assert.doesNotMatch(successBadge, /variant="success"/);
|
||||||
|
assert.match(badgeVariants({ variant: 'success' }), /bg-success/);
|
||||||
|
assert.match(badgeVariants({ variant: 'warning' }), /bg-warning/);
|
||||||
|
assert.match(badgeVariants({ variant: 'info' }), /bg-info/);
|
||||||
|
|
||||||
|
const avatar = await render(
|
||||||
|
Avatar,
|
||||||
|
{ size: 'xl', shape: 'square' },
|
||||||
|
{ default: () => h(AvatarFallback, {}, () => 'NS') },
|
||||||
|
);
|
||||||
|
assert.match(avatar, /data-size="xl"/);
|
||||||
|
assert.match(avatar, /data-shape="square"/);
|
||||||
|
assert.match(avatar, /size-16/);
|
||||||
|
assert.match(avatar, /rounded-sm/);
|
||||||
|
assert.match(avatar, />NS</);
|
||||||
|
assert.match(avatarVariants({ size: 'sm' }), /size-6/);
|
||||||
|
|
||||||
|
const userAvatar = await render(UserAvatar, {
|
||||||
|
rounded: true,
|
||||||
|
size: 'lg',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Nico Schröder',
|
||||||
|
email: 'nico@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: null,
|
||||||
|
avatar_is_generated: false,
|
||||||
|
email_verified_at: null,
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-09-09T00:00:00Z',
|
||||||
|
updated_at: '2026-09-09T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(userAvatar, /data-size="lg"/);
|
||||||
|
assert.match(userAvatar, /data-shape="circle"/);
|
||||||
|
assert.match(userAvatar, /aria-label="Nico Schröder"/);
|
||||||
|
assert.match(userAvatar, />NS</);
|
||||||
|
|
||||||
|
console.log('UI component behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user